commit 36ca44deac67a05d8d3c83be406e84d65ec1fc45 Author: wehub-resource-sync Date: Mon Jul 13 12:30:04 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/1.bug.yml b/.github/ISSUE_TEMPLATE/1.bug.yml new file mode 100644 index 0000000..c3ef17f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1.bug.yml @@ -0,0 +1,46 @@ +name: Bug report 🐛 +description: Report a bug or unexpected behavior. +title: "[Bug] " +labels: ['status: needs check'] +body: + - type: markdown + attributes: + value: | + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ + - type: checkboxes + attributes: + label: Self check + options: + - label: I'm on the latest version and searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. + required: true + - type: textarea + attributes: + label: Environment + description: "Version (`cow status`), OS, Python version, install method, model & channel." + placeholder: | + Version: v1.2.0 + OS: macOS / Linux / Windows / Docker + Python: 3.11 + Install: installer / Docker / source + Model & channel: deepseek-v4-flash, web + validations: + required: true + - type: textarea + attributes: + label: What happened? + description: "Steps to reproduce, what you expected, and what happened instead. Screenshots welcome." + placeholder: | + 1. ... + 2. ... + + Expected: ... + Actual: ... + validations: + required: true + - type: textarea + attributes: + label: Logs + description: "Relevant logs from `run.log` (set `\"debug\": true` for more detail). ⚠️ Redact your API keys." + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/2.feature.yml b/.github/ISSUE_TEMPLATE/2.feature.yml new file mode 100644 index 0000000..8b53ff9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2.feature.yml @@ -0,0 +1,33 @@ +name: Feature request 🚀 +description: Suggest a new idea or improvement. +title: "[Feature] " +labels: ['status: needs check'] +body: + - type: markdown + attributes: + value: | + > 💡 English is recommended so global developers can help. 推荐使用英文提交,谢谢 ❤️ + - type: checkboxes + attributes: + label: Self check + options: + - label: I searched [existing issues](https://github.com/zhayujie/CowAgent/issues) (incl. closed) — no duplicate. + required: true + - type: textarea + attributes: + label: What's the problem? + description: "The pain point or what's not working for you right now." + validations: + required: true + - type: textarea + attributes: + label: What would you like? + description: "How you'd expect it to work. Examples, sketches, or links welcome." + validations: + required: false + - type: checkboxes + attributes: + label: Contribution + options: + - label: I'd be interested in helping implement this. + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a37a397 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: 📖 Documentation + url: https://docs.cowagent.ai + about: Setup guides, configuration, and FAQ. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..d8a2741 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,22 @@ + + +## What does this PR do? + + + +## Type of change + +- [ ] Bug fix +- [ ] New feature +- [ ] Docs +- [ ] Refactor / chore + +## Checklist + +- [ ] I have read the [Contributing Guide](https://github.com/zhayujie/CowAgent/blob/master/CONTRIBUTING.md) +- [ ] I tested this change locally +- [ ] Code comments and docs are in English +- [ ] Linked related issue (if any): closes # diff --git a/.github/scripts/register-releases.mjs b/.github/scripts/register-releases.mjs new file mode 100644 index 0000000..5109ce9 --- /dev/null +++ b/.github/scripts/register-releases.mjs @@ -0,0 +1,116 @@ +// Build the D1 upsert SQL for a desktop release from the files in a directory. +// +// Each mac release has TWO artifacts that map to a SINGLE D1 row: +// - -.dmg -> manual download (filename / size / sha512) +// - -.zip -> auto-update (update_filename / update_size / +// update_sha512) +// electron-updater's MacUpdater can only consume a zip, never a dmg, so the +// feed serves the zip while the website serves the dmg. Windows has only the +// .exe (stored in the main columns; it's both the download and the update). +// +// We emit ONE `INSERT OR REPLACE` per (version, platform) carrying BOTH halves, +// because two replaces on the same primary key would drop whichever came first. +// +// Usage: +// node register-releases.mjs --dir dist --version 1.2.0 \ +// --sql out.sql [--latest] +// +// --latest mark these rows is_latest=1 AND clear the previous latest for +// each platform (used by the publish/promote workflow). Without it +// rows are written unpublished (is_latest=0) — the build stage. +// +// sha512 is base64 (the exact format electron-updater validates). + +import { execSync } from 'node:child_process' +import fs from 'node:fs' + +function arg(name, fallback = undefined) { + const i = process.argv.indexOf(`--${name}`) + if (i === -1) return fallback + const next = process.argv[i + 1] + // Boolean flag (no value or next token is another flag). + if (next === undefined || next.startsWith('--')) return true + return next +} + +const dir = arg('dir', 'dist') +const version = arg('version') +const sqlPath = arg('sql', 'd1.sql') +const makeLatest = arg('latest', false) === true + +if (!version) { + console.error('register-releases: --version is required') + process.exit(1) +} + +const sha512 = (f) => + execSync(`openssl dgst -sha512 -binary "${f}" | openssl base64 -A`, { + shell: '/bin/bash', + }) + .toString() + .trim() + +// SQL-escape single quotes (base64/keys shouldn't contain them, but be safe). +const q = (s) => String(s).replace(/'/g, "''") + +// platform -> { main: {key,size,sha}, upd: {key,size,sha} } +const rows = {} + +for (const base of fs.readdirSync(dir)) { + const f = `${dir}/${base}` + if (fs.statSync(f).isDirectory()) continue + + let platform + let slot + if (/arm64\.dmg$/.test(base)) { + platform = 'mac-arm64' + slot = 'main' + } else if (/x64\.dmg$/.test(base)) { + platform = 'mac-x64' + slot = 'main' + } else if (/arm64\.zip$/.test(base)) { + platform = 'mac-arm64' + slot = 'upd' + } else if (/x64\.zip$/.test(base)) { + platform = 'mac-x64' + slot = 'upd' + } else if (/\.exe$/.test(base)) { + platform = 'win' + slot = 'main' + } else { + console.log('Skipping unrecognized artifact:', base) + continue + } + + rows[platform] ||= {} + rows[platform][slot] = { + key: `v${version}/${base}`, + size: fs.statSync(f).size, + sha: sha512(f), + } +} + +if (Object.keys(rows).length === 0) { + console.error('register-releases: no recognized artifacts in', dir) + process.exit(1) +} + +const isLatest = makeLatest ? 1 : 0 +const sql = [] +for (const [platform, r] of Object.entries(rows)) { + const m = r.main || { key: '', size: 0, sha: '' } + const u = r.upd || { key: '', size: 0, sha: '' } + if (makeLatest) { + // Clear the previous latest for this platform before promoting the new row. + sql.push(`UPDATE releases SET is_latest = 0 WHERE platform = '${platform}';`) + } + sql.push( + `INSERT OR REPLACE INTO releases ` + + `(version, platform, filename, size, sha512, update_filename, update_size, update_sha512, is_latest) ` + + `VALUES ('${version}', '${platform}', '${q(m.key)}', ${m.size}, '${q(m.sha)}', ` + + `'${q(u.key)}', ${u.size}, '${q(u.sha)}', ${isLatest});` + ) +} + +fs.writeFileSync(sqlPath, sql.join('\n') + '\n') +console.log(`register-releases: wrote ${sql.length} statement(s) to ${sqlPath}`) diff --git a/.github/workflows/deploy-image-arm.yml b/.github/workflows/deploy-image-arm.yml new file mode 100644 index 0000000..2beaeb4 --- /dev/null +++ b/.github/workflows/deploy-image-arm.yml @@ -0,0 +1,77 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +name: Create and publish a Docker image + +on: + push: + branches: ['master'] + create: +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push-image: + if: github.repository == 'zhayujie/CowAgent' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v1 + + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v1 + + - name: Available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: | + ${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat + ${{ env.REGISTRY }}/zhayujie/cowagent + tags: | + type=raw,value=latest-arm64,enable={{is_default_branch}} + type=ref,event=branch,suffix=-arm64 + type=ref,event=tag,suffix=-arm64 + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + file: ./docker/Dockerfile.latest + platforms: linux/arm64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - uses: actions/delete-package-versions@v4 + with: + package-name: 'chatgpt-on-wechat' + package-type: 'container' + min-versions-to-keep: 10 + delete-only-untagged-versions: 'true' + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/deploy-image.yml b/.github/workflows/deploy-image.yml new file mode 100644 index 0000000..d9ac0be --- /dev/null +++ b/.github/workflows/deploy-image.yml @@ -0,0 +1,75 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +name: Create and publish a Docker image + +on: + push: + branches: ['master'] + create: +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + DOCKERHUB_IMAGE: zhayujie/chatgpt-on-wechat + +jobs: + build-and-push-image: + if: github.repository == 'zhayujie/CowAgent' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Container registry + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4 + with: + images: | + zhayujie/chatgpt-on-wechat + zhayujie/cowagent + ${{ env.REGISTRY }}/zhayujie/chatgpt-on-wechat + ${{ env.REGISTRY }}/zhayujie/cowagent + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=ref,event=branch + type=ref,event=tag + + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + context: . + push: true + file: ./docker/Dockerfile.latest + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + - uses: actions/delete-package-versions@v4 + with: + package-name: 'chatgpt-on-wechat' + package-type: 'container' + min-versions-to-keep: 10 + delete-only-untagged-versions: 'true' + token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/publish-desktop.yml b/.github/workflows/publish-desktop.yml new file mode 100644 index 0000000..6f2270b --- /dev/null +++ b/.github/workflows/publish-desktop.yml @@ -0,0 +1,154 @@ +name: Publish Desktop + +# STAGE 3 of the decoupled release pipeline: PROMOTE a built + notarized version +# to "live". By this point: +# - stage 1 (Release Desktop) built the installers, mirrored them to R2, and +# registered them in D1 as unpublished (is_latest=0); +# - stage 2 (local desktop/build/notarize-dmg.sh) notarized + stapled the mac +# dmgs and re-uploaded the stapled bytes to R2. +# +# This workflow, triggered manually with the version to publish: +# 1. pulls every artifact for that version back from R2, +# 2. recomputes sha512 from the real (stapled) bytes and updates D1, +# 3. flips is_latest=1 for that version (clearing the previous latest per +# platform) UNLESS it's a pre-release, which is recorded but never latest, +# 4. creates/updates the GitHub Release and attaches the installers. +# +# Run only after the mac dmgs for this version are notarized + re-uploaded. +on: + workflow_dispatch: + inputs: + version: + description: "Version to publish (e.g. 1.2.0). Must already be built + notarized." + type: string + required: true + make_latest: + description: "Mark this version as latest on the site (uncheck for a dry re-hash only)." + type: boolean + default: true + github_release: + description: "Create/update the GitHub Release and attach installers." + type: boolean + default: true + +permissions: + contents: write + +jobs: + publish: + name: Publish ${{ github.event.inputs.version }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Guard on Cloudflare secrets + id: guard + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -z "$CF_TOKEN" ]; then + echo "::error::CLOUDFLARE_API_TOKEN not set — cannot publish." + exit 1 + fi + + - name: Resolve version artifacts from D1 + id: rows + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ github.event.inputs.version }} + run: | + # The build stage inserted one row per artifact with filename = v/. + # Read them back so we know exactly which objects to pull from R2. + out="$(npx --yes wrangler@latest d1 execute cow-desktop --remote --json \ + --command "SELECT platform, filename, update_filename FROM releases WHERE version = '${VER}';")" + echo "$out" + echo "$out" | node -e ' + const fs = require("fs"); + const data = JSON.parse(fs.readFileSync(0, "utf8")); + const rows = (Array.isArray(data) ? data : [data]) + .flatMap(r => (r.results || [])); + if (!rows.length) { + console.error("No D1 rows for this version — did stage 1 (build) run?"); + process.exit(1); + } + fs.writeFileSync(process.env.GITHUB_OUTPUT, "count=" + rows.length + "\n", { flag: "a" }); + fs.writeFileSync("rows.json", JSON.stringify(rows)); + ' + + - name: Download version artifacts from R2 + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.R2_BUCKET != '' && vars.R2_BUCKET || 'cow-skills' }} + run: | + mkdir -p dist + # Pull BOTH the manual-download file (filename: dmg/exe) and the mac + # auto-update file (update_filename: zip) for every row. Keys are + # "v/"; the R2 key is "desktop/". + for key in $(node -e 'JSON.parse(require("fs").readFileSync("rows.json")).forEach(r => { if (r.filename) console.log(r.filename); if (r.update_filename) console.log(r.update_filename); })'); do + base="$(basename "$key")" + r2key="desktop/${key}" + echo "==> Downloading r2://${R2_BUCKET}/${r2key} -> dist/${base}" + npx --yes wrangler@latest r2 object get "${R2_BUCKET}/${r2key}" \ + --file "dist/${base}" --remote + done + echo "Downloaded:"; ls -la dist + + - name: Reminder — mac dmgs must be notarized before publishing + run: | + # Stapling can only be validated on macOS (xcrun stapler validate), + # which this Linux runner doesn't have. The authoritative check runs in + # stage 2 (desktop/build/notarize-dmg.sh) before re-uploading to R2. + # This step is just a loud reminder in the log. + echo "::notice::Publishing assumes the mac dmgs pulled from R2 are already notarized + stapled (stage 2). If you skipped stage 2, users will hit Gatekeeper warnings." + ls -la dist/*.dmg 2>/dev/null || echo "(no dmg in this version — win-only publish)" + + - name: Update D1 (recompute sha512 + set latest) + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ github.event.inputs.version }} + MAKE_LATEST: ${{ github.event.inputs.make_latest }} + run: | + # Pre-releases (e.g. 1.2.0-beta / -rc.1 / -test) are recorded but never + # become latest, so the site keeps serving the last stable build. + case "$VER" in + *-*) is_pre=1 ;; + *) is_pre=0 ;; + esac + if [ "$MAKE_LATEST" = "true" ] && [ "$is_pre" = "0" ]; then + latest_flag="--latest"; echo "==> Publishing $VER as latest." + else + latest_flag=""; echo "==> Publishing $VER without latest flag (pre-release or dry re-hash)." + fi + + # Re-hash the real (stapled) bytes and re-store every row with both the + # dmg (manual) and mac zip (auto-update) columns. Same script as the + # build stage; --latest also clears the previous latest per platform. + node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql $latest_flag + echo "==> D1 statements:"; cat d1.sql + npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql + + - name: Create/update GitHub Release and attach installers + if: github.event.inputs.github_release == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VER: ${{ github.event.inputs.version }} + run: | + tag="v${VER}" + case "$VER" in + *-*) prerelease="--prerelease" ;; + *) prerelease="" ;; + esac + if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$tag" --repo "$GITHUB_REPOSITORY" \ + --title "$tag" --generate-notes $prerelease + fi + # --clobber so re-runs overwrite the stapled/updated assets. The mac + # zip is the auto-update artifact; attach it too so the GitHub Release + # is a complete mirror (nullglob avoids errors when a type is absent). + shopt -s nullglob + gh release upload "$tag" dist/*.dmg dist/*.zip dist/*.exe \ + --repo "$GITHUB_REPOSITORY" --clobber diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2aeaf0e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,301 @@ +name: Release Desktop + +# STAGE 1 of the decoupled release pipeline: BUILD ONLY. +# Builds the desktop client for macOS (arm64 + x64) and Windows (x64), mirrors +# the installers to R2, and registers them in D1 as UNPUBLISHED (is_latest=0) +# so the website keeps serving the previous release. It does NOT notarize +# (Apple's notary service stalls this large bundle for hours) and does NOT +# create a GitHub Release. +# +# Full flow: +# 1. (this workflow) build + upload to R2 + D1 as unpublished. +# 2. (local) download the mac dmgs, run desktop/build/notarize-dmg.sh to +# notarize + staple + re-upload the stapled dmgs to R2. +# 3. (Publish Desktop workflow) flip D1 is_latest=1 and attach GitHub +# Release assets — makes the version live on the site. +# +# Manual only: run stage 1 via workflow_dispatch. Tag pushes do NOT trigger a +# build, so cutting a release tag never rebuilds installers or overwrites R2. +on: + workflow_dispatch: + inputs: + version: + description: "Version to stamp (e.g. 1.0.0-test). Used for package.json and R2 path." + type: string + default: "0.0.0-dev" + publish_r2: + description: "Upload installers to R2 + register in D1 (needs Cloudflare secrets)" + type: boolean + default: false + +permissions: + contents: write + +jobs: + build: + name: Build ${{ matrix.name }} + runs-on: ${{ matrix.os }} + strategy: + # Don't cancel the other platforms if one fails — we want to see all + # failures in a single run. + fail-fast: false + matrix: + include: + - name: macOS arm64 + os: macos-14 + platform: mac + arch: arm64 + eb_flags: --mac --arm64 + - name: macOS x64 + os: macos-15-intel + platform: mac + arch: x64 + eb_flags: --mac --x64 + - name: Windows x64 + os: windows-latest + platform: win + arch: x64 + eb_flags: --win --x64 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Derive version + # Tag push: strip the leading "v" from GITHUB_REF_NAME (e.g. v1.2.0). + # Manual dispatch: use the provided version input. + id: ver + shell: bash + run: | + if [ "${{ github.event_name }}" = "push" ]; then + ref="${GITHUB_REF_NAME:-}" + echo "version=${ref#v}" >> "$GITHUB_OUTPUT" + else + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Build Python backend (PyInstaller) + shell: bash + run: | + python -m pip install --upgrade pip + pip install -r desktop/build/requirements-desktop.txt + pip install pyinstaller + # Run from repo root so the spec's relative datas resolve correctly. + pyinstaller desktop/build/cowagent-backend.spec \ + --noconfirm \ + --distpath desktop/build/dist \ + --workpath desktop/build/build-work + + - name: Install desktop deps + working-directory: desktop + run: npm ci + + - name: Write version into package.json + working-directory: desktop + shell: bash + run: npm version "${{ steps.ver.outputs.version }}" --no-git-tag-version --allow-same-version + + # Compile renderer + main in its OWN step, alone, so the npm.cmd batch + # wrapper (see the note on the build step below) can't take out anything + # after it. + - name: Compile (vite + tsc) + working-directory: desktop + shell: bash + run: npm run build + + - name: Build & publish (electron-builder) + working-directory: desktop + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Signing secrets are passed through as-is; we only export them to the + # environment below when non-empty. An empty CSC_LINK would make + # electron-builder try to load a bogus certificate and fail, so unset + # is the correct state for unsigned builds. + MAC_CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + MAC_CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} + WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + run: | + # Pick the signing cert for THIS platform only. The mac and win secrets + # are both present in the job env, but a mac cert must never leak into a + # Windows build (electron-builder would try to load it and fail), and + # vice versa. electron-builder reads a single CSC_LINK/CSC_KEY_PASSWORD + # pair, so we set it per-platform. An empty CSC_LINK is treated by + # electron-builder as a broken cert path, so we leave it entirely unset + # for an unsigned build. + # + # NOTE: we only ever `export`, never `unset`, GitHub-injected env vars + # (an `unset` can return non-zero and abort under errexit). + case "${{ matrix.platform }}" in + mac) + if [ -n "$MAC_CSC_LINK" ]; then + export CSC_LINK="$MAC_CSC_LINK" + export CSC_KEY_PASSWORD="$MAC_CSC_KEY_PASSWORD" + fi + ;; + win) + if [ -n "$WIN_CSC_LINK" ]; then + export CSC_LINK="$WIN_CSC_LINK" + export CSC_KEY_PASSWORD="$WIN_CSC_KEY_PASSWORD" + fi + ;; + esac + + # Never let electron-builder publish: our publish target is a generic + # (read-only) feed served from R2/D1, which it can't upload to. We mirror + # installers to R2 and register them in D1 ourselves (publish-r2 job). + # `--publish never` still emits the latest*.yml files. + # + # CONFIG PER PLATFORM: the dynamic electron-builder.js only exists to + # inject mac.binaries (the backend Mach-O files to hardened-sign for + # notarization) — it's a pure no-op on Windows. Passing --config on + # Windows was what silently broke the Windows build (it produced no + # installer while the job still reported success; Windows worked fine + # before --config was introduced). So Windows uses the plain + # package.json build config and only mac uses the dynamic one. + # + # Invoke via `node ` rather than `npx`: on Windows `npx` is + # npx.cmd (a batch wrapper) and running it from this Git Bash step can + # make bash return before the wrapped process finishes. node skips it. + case "${{ matrix.platform }}" in + mac) config_arg="--config electron-builder.js" ;; + *) config_arg="" ;; + esac + node node_modules/electron-builder/cli.js ${{ matrix.eb_flags }} $config_arg --publish never + + # Upload artifacts regardless of outcome, so a failed run still surfaces + # the built installers (and, on success, the notarized+stapled dmg). + - name: Upload artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + # One bundle per platform/arch so the publish job can collect them all. + name: cowagent-${{ matrix.platform }}-${{ matrix.arch }} + path: | + desktop/release/*.dmg + desktop/release/*.zip + desktop/release/*.exe + desktop/release/*.yml + desktop/release/*.blockmap + if-no-files-found: ignore + retention-days: 7 + + # Mirror the release installers to R2 (CDN-backed) and register them in D1 so + # cowagent.ai/download/{platform}/latest can resolve and count downloads. + # Runs only on tag pushes, and is a no-op (skips) until the Cloudflare secrets + # are configured, so it never blocks unsigned/dry builds. + publish-r2: + name: Publish to R2 + D1 + # Require every platform in the build matrix to succeed before publishing, + # so a release on R2/D1 is always complete (all installers present) rather + # than partial. needs: build already gates on all matrix jobs succeeding. + needs: build + runs-on: ubuntu-latest + # Run on a tag push, or on a manual dispatch when publish_r2 is checked. + if: >- + (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || + (github.event_name == 'workflow_dispatch' && github.event.inputs.publish_r2 == 'true') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Guard on Cloudflare secrets + id: guard + env: + CF_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + run: | + if [ -n "$CF_TOKEN" ]; then + echo "enabled=true" >> "$GITHUB_OUTPUT" + else + echo "enabled=false" >> "$GITHUB_OUTPUT" + echo "::notice::CLOUDFLARE_API_TOKEN not set — skipping R2/D1 publish." + fi + + - name: Derive version + if: steps.guard.outputs.enabled == 'true' + id: ver + run: | + if [ "${{ github.event_name }}" = "push" ]; then + echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + else + echo "version=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT" + fi + + - name: Download all build artifacts + if: steps.guard.outputs.enabled == 'true' + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Stage installers + if: steps.guard.outputs.enabled == 'true' + id: stage + run: | + mkdir -p dist + # Flatten installers + their .blockmap (used by electron-updater for + # differential downloads) from every per-platform artifact dir. The + # .yml feed is generated dynamically by the /update Function from D1, + # so the yml files themselves don't need to go to R2. + # .zip is the mac auto-update artifact (electron-updater's MacUpdater + # can ONLY consume zip, not dmg — the dmg is for manual downloads). + find artifacts -type f \( -name '*.dmg' -o -name '*.zip' -o -name '*.exe' -o -name '*.blockmap' \) -exec cp {} dist/ \; + echo "Staged files:"; ls -la dist + # When the whole matrix failed there's nothing to publish; flag it so + # the R2/D1 steps skip instead of writing an empty/partial release. + if [ -n "$(ls -A dist 2>/dev/null)" ]; then + echo "has_files=true" >> "$GITHUB_OUTPUT" + else + echo "has_files=false" >> "$GITHUB_OUTPUT" + echo "::warning::No installers found in any artifact — skipping R2/D1 publish." + fi + + - name: Upload installers to R2 + if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ steps.ver.outputs.version }} + run: | + # Reuse the existing cow-skills bucket under a desktop/ prefix; this + # is served by the cdn.cowagent.ai custom domain. + for f in dist/*; do + base="$(basename "$f")" + key="desktop/v${VER}/${base}" + echo "==> Uploading $base -> r2://cow-skills/$key" + npx --yes wrangler@latest r2 object put "cow-skills/$key" \ + --file "$f" --remote + done + + - name: Register release rows in D1 + if: steps.guard.outputs.enabled == 'true' && steps.stage.outputs.has_files == 'true' + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + VER: ${{ steps.ver.outputs.version }} + run: | + # This build job ALWAYS registers rows as unpublished (is_latest=0), so + # /download/

/latest keeps serving the previous release and the new + # version stays invisible on the site. macOS dmgs still need to be + # notarized+stapled locally (build/notarize-dmg.sh) before they're + # safe to ship. Promotion to latest happens later, only after + # notarization, via the separate "Publish Desktop" workflow. + echo "==> Registering $VER as unpublished (is_latest=0)." + + # Build one upsert per (version, platform) carrying both the dmg + # (manual download) and the mac zip (auto-update) columns. See + # .github/scripts/register-releases.mjs for the mapping. No --latest + # here: rows stay unpublished until the publish workflow promotes them. + node .github/scripts/register-releases.mjs --dir dist --version "$VER" --sql d1.sql + echo "==> D1 statements:"; cat d1.sql + npx --yes wrangler@latest d1 execute cow-desktop --remote --file d1.sql diff --git a/.github/workflows/test-windows-bash.yml b/.github/workflows/test-windows-bash.yml new file mode 100644 index 0000000..4a39dbe --- /dev/null +++ b/.github/workflows/test-windows-bash.yml @@ -0,0 +1,32 @@ +name: Windows Bash Streaming Tests + +on: + workflow_dispatch: + pull_request: + paths: + - "agent/tools/bash/bash.py" + - "tests/test_bash_streaming.py" + - ".github/workflows/test-windows-bash.yml" + +jobs: + windows-bash-tests: + runs-on: windows-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install pytest + python -m pip install -r requirements.txt + + - name: Run Windows Bash streaming tests + run: python -m pytest tests/test_bash_streaming.py -v diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9cbb011 --- /dev/null +++ b/.gitignore @@ -0,0 +1,64 @@ +.DS_Store +.idea +.vscode +.venv +.vs +__pycache__/ +venv* +*.pyc +python +config.json +QR.png +nohup.out +tmp +plugins.json +*.log +logs/ +workspace +config.yaml +user_datas.pkl +chatgpt_tool_hub/ +plugins/**/ +!plugins/bdunit +!plugins/dungeon +!plugins/finish +!plugins/godcmd +!plugins/tool +!plugins/banwords +!plugins/banwords/**/ +plugins/banwords/__pycache__ +plugins/banwords/lib/__pycache__ +!plugins/hello +!plugins/role +!plugins/keyword +!plugins/linkai +!plugins/cow_cli +client_config.json +ref/ +**/.dev.vars +.cursor/ +local/ +node_modules/ + +# cow cli +dist/ +build/ +*.egg-info/ +.cow.pid + +# Desktop backend packaging: keep the source files (spec/requirements/script) +# tracked even though the generic build/ rule above ignores them, but never +# track the build outputs or local venv. +!desktop/build/ +desktop/build/* +!desktop/build/cowagent-backend.spec +!desktop/build/requirements-desktop.txt +!desktop/build/build-backend.sh +!desktop/build/entitlements.mac.plist +!desktop/build/notarize-dmg.sh + +# Icon authoring scratch dir: intermediate assets used to produce the final +# icons. Only the finished icons under desktop/resources/ should be committed. +desktop/resources/.icon-work/ + +.wrangler/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4a9d7d8 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to CowAgent + +Thanks for taking the time to contribute! 🎉 CowAgent is built by a global +community, and contributions of all sizes are welcome — from typo fixes to new +features. + +## Language policy + +To keep the project accessible to a global community, **please write issues, +pull requests, code comments, and commit messages in English.** + +> 为方便全球开发者协作,请尽量使用**英文**提交 issue、PR、代码注释与 +> commit message。不必担心英文不完美——表达清楚即可,工具翻译也完全没问题。感谢理解 ❤️ + +## Reporting issues + +Found a bug or have an idea? [Open an issue](https://github.com/zhayujie/CowAgent/issues/new/choose). + +Before opening one, please search existing issues (including closed ones) to +avoid duplicates, and make sure you're on the latest version. + +## Submitting a pull request + +1. **Fork** the repo and create a branch from `master` + (e.g. `feat/web-search`, `fix/telegram-reconnect`). +2. Make your change. Keep it focused — one logical change per PR. +3. Follow the existing code style. Write comments and docstrings in English. +4. Run the app locally to confirm your change works. +5. Open a PR with a clear title and a short description of **what** and **why**. + +We keep the bar friendly: clear, focused, and working is enough. Maintainers are +happy to help polish details during review. + +### Commit & PR titles + +Use a short, imperative summary. The [Conventional Commits](https://www.conventionalcommits.org/) +style is preferred but not required: + +``` +feat: add web search tool +fix: reconnect Telegram websocket on timeout +docs: clarify Docker setup +``` + +## Development setup + +See the [Install from Source](https://docs.cowagent.ai/guide/manual-install) +guide. In short: + +```bash +git clone https://github.com/zhayujie/CowAgent.git +cd CowAgent +pip install -r requirements.txt +pip install -e . +cow start +``` + +## Code of conduct + +Be respectful and constructive. We want CowAgent to be a welcoming place for +everyone. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d3bbb7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,3 @@ +FROM ghcr.io/zhayujie/chatgpt-on-wechat:latest + +ENTRYPOINT ["/entrypoint.sh"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fd03f7c --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2022 zhayujie + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cb88b3 --- /dev/null +++ b/README.md @@ -0,0 +1,279 @@ +

CowAgent

+ +

+ Latest release + License: MIT + Stars + Docs +

+ +

+ zhayujie%2FCowAgent | Trendshift +

+ +

+ [English] | [中文] | [繁體中文] | [日本語] +

+ +**CowAgent** is an open-source super AI assistant that proactively plans tasks, controls your computer and external services, creates and runs Skills, builds a personal knowledge base and long-term memory, and grows alongside you through self-evolution — a reference implementation of Agent Harness engineering. + +CowAgent is lightweight, easy to deploy, and built to extend. Plug in any major LLM provider and run it 24/7 on a personal computer or server, across the web and all major IM platforms. + +

+ 🌐 Website  ·  + 📖 Docs  ·  + 🚀 Quick Start  ·  + 🧩 Skill Hub  ·  + 💻 Download  ·  + ☁️ Try Online +

+ +
+ +## 🌟 Highlights + +| Capability | Description | +| :--- | :--- | +| [Planning](https://docs.cowagent.ai/intro/architecture) | Decomposes complex tasks and executes them step by step, looping over tools until the goal is reached | +| [Memory](https://docs.cowagent.ai/memory/index) | Three-tier architecture (context → daily → core), automatic Deep Dream distillation, hybrid keyword + vector retrieval | +| [Knowledge](https://docs.cowagent.ai/knowledge/index) | Auto-curates structured knowledge into a Markdown wiki, builds an evolving knowledge graph with visual browsing | +| [Evolution](https://docs.cowagent.ai/memory/self-evolution) | Self-Evolution reviews conversations automatically to improve skills, follow up on unfinished tasks, and consolidate memory and knowledge, growing through everyday use | +| [Skills](https://docs.cowagent.ai/skills/index) | One-click install from [Skill Hub](https://skills.cowagent.ai/), GitHub, ClawHub; or create custom skills via natural-language conversation | +| [Tools](https://docs.cowagent.ai/tools/index) | Built-in file I/O, terminal, browser, scheduler, memory retrieval, web search, and 10+ more tools — with native MCP integration | +| [Channels](https://docs.cowagent.ai/channels/index) | Integrates with Web, WeChat, Feishu, DingTalk, WeCom, QQ, Official Accounts, Telegram, and Slack | +| Multimodal | First-class support for text, images, voice, and files — recognition, generation, and delivery | +| [Models](https://docs.cowagent.ai/models/index) | Claude, GPT, Gemini, DeepSeek, Qwen, GLM, Kimi, MiniMax, Doubao, and more — swap providers from the Web console with one click | +| [Deploy](https://docs.cowagent.ai/guide/quick-start) | One-line installer, unified Web console, multiple deployment modes (local, Docker, server) | + +
+ +## 🏗️ Architecture + +CowAgent Architecture + +CowAgent is a complete **Agent Harness**: messages flow in through **Channels**; the **Agent Core** plans and reasons over memory, knowledge, and the available tools and skills; **Models** generate the response, which is sent back through the originating channel. Every layer is decoupled and independently extensible. + +Read more in [Architecture](https://docs.cowagent.ai/intro/architecture). + +
+ +## 🚀 Quick Start + +A one-line installer takes care of dependencies, configuration, and startup: + +**Linux / macOS:** + +```bash +bash <(curl -fsSL https://cdn.link-ai.tech/code/cow/run.sh) +``` + +**Windows (PowerShell):** + +```powershell +irm https://cdn.link-ai.tech/code/cow/run.ps1 | iex +``` + +**Docker:** + +```bash +curl -O https://cdn.link-ai.tech/code/cow/docker-compose.yml +docker compose up -d +``` + +Once started, open `http://localhost:9899` to access the **Web console** — your one-stop hub to chat with the Agent, configure models, connect channels, and install skills. + +> Deploying on a server? Set `web_host` to `0.0.0.0` in `config.json` to make the console reachable from outside, and set `web_password` to protect it. Don't forget to open port `9899` in your firewall or security group. + +> 📖 Detailed guides: [Quick Start](https://docs.cowagent.ai/guide/quick-start) · [Install from Source](https://docs.cowagent.ai/guide/manual-install) · [Upgrade](https://docs.cowagent.ai/guide/upgrade) + +After installation, manage the service with the [cow CLI](https://docs.cowagent.ai/cli/index): + +```bash +cow start | stop | restart # service control +cow status | logs # status and logs +cow update # pull latest code and restart +cow skill install # install a skill +cow install-browser # install browser automation +``` + +> 💻 Desktop client: download the **[CowAgent Desktop client](https://cowagent.ai/download/)** (macOS / Windows) — the backend is bundled, ready to use out of the box. + +
+ +## 🤖 Models + +CowAgent supports all mainstream LLM providers. **Chat, vision, image generation, ASR/TTS, and embeddings** can each be routed to a different vendor. Providers are configured directly in the Web console — no manual file editing required. + +| Provider | Featured Models | Chat | Vision | Image Gen | ASR | TTS | Embedding | +| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | +| [Claude](https://docs.cowagent.ai/models/claude) | claude-sonnet-5 | ✅ | ✅ | | | | | +| [OpenAI](https://docs.cowagent.ai/models/openai) | gpt-5.5, o-series | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Gemini](https://docs.cowagent.ai/models/gemini) | gemini-3.5-flash | ✅ | ✅ | ✅ | | | | +| [DeepSeek](https://docs.cowagent.ai/models/deepseek) | deepseek-v4-flash / pro | ✅ | | | | | | +| [Qwen](https://docs.cowagent.ai/models/qwen) | qwen3.7-plus | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [GLM](https://docs.cowagent.ai/models/glm) | glm-5.2, glm-5v-turbo | ✅ | ✅ | | ✅ | | ✅ | +| [Doubao](https://docs.cowagent.ai/models/doubao) | doubao-seed-2.1 series | ✅ | ✅ | ✅ | | | ✅ | +| [Kimi](https://docs.cowagent.ai/models/kimi) | kimi-k2.7-code | ✅ | ✅ | | | | | +| [MiniMax](https://docs.cowagent.ai/models/minimax) | MiniMax-M3 | ✅ | ✅ | ✅ | | ✅ | | +| [ERNIE](https://docs.cowagent.ai/models/qianfan) | ernie-5.1 | ✅ | ✅ | | | | | +| [MiMo](https://docs.cowagent.ai/models/mimo) | mimo-v2.5 / pro | ✅ | ✅ | | | ✅ | | +| [LinkAI](https://docs.cowagent.ai/models/linkai) | One key for 100+ models | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Custom](https://docs.cowagent.ai/models/custom) | Local models / third-party proxy | ✅ | | | | | | + +> For details on each provider, see the [Models overview](https://docs.cowagent.ai/models/index). + +
+ +## 💬 Channels + +A single Agent instance can serve multiple channels in parallel. Most channels can be onboarded right from the Web console. + +| Channel | Text | Image | File | Voice | Group | +| --- | :-: | :-: | :-: | :-: | :-: | +| [Web Console](https://docs.cowagent.ai/channels/web) (default) | ✅ | ✅ | ✅ | ✅ | | +| [Telegram](https://docs.cowagent.ai/channels/telegram) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [Slack](https://docs.cowagent.ai/channels/slack) | ✅ | ✅ | ✅ | | ✅ | +| [Discord](https://docs.cowagent.ai/channels/discord) | ✅ | ✅ | ✅ | | ✅ | +| [WeChat](https://docs.cowagent.ai/channels/weixin) | ✅ | ✅ | ✅ | ✅ | | +| [Feishu / Lark](https://docs.cowagent.ai/channels/feishu) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [DingTalk](https://docs.cowagent.ai/channels/dingtalk) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [WeCom Bot](https://docs.cowagent.ai/channels/wecom-bot) | ✅ | ✅ | ✅ | ✅ | ✅ | +| [QQ](https://docs.cowagent.ai/channels/qq) | ✅ | ✅ | ✅ | | ✅ | +| [WeCom App](https://docs.cowagent.ai/channels/wecom) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Customer Service](https://docs.cowagent.ai/channels/wechat-kf) | ✅ | ✅ | ✅ | ✅ | | +| [WeChat Official Account](https://docs.cowagent.ai/channels/wechatmp) | ✅ | ✅ | | ✅ | | + +> See the [Channels overview](https://docs.cowagent.ai/channels/index) for setup details. + +CowAgent Web Console + +*The Web console is the default channel and the unified entry point to configure models, channels, skills, memory, and more.* + +
+ +## 🧠 Memory & Knowledge Base + +**Long-term memory** uses a three-tier architecture: conversation context (short-term) → daily memory (mid-term) → MEMORY.md (long-term). A nightly **Deep Dream** pass distills scattered memories into refined long-term entries and a narrative journal. See [Long-term Memory](https://docs.cowagent.ai/memory/index) · [Deep Dream](https://docs.cowagent.ai/memory/deep-dream). + +**Personal knowledge base** complements the time-ordered memory by organizing structured knowledge **by topic**. The Agent automatically curates valuable information from conversations, maintains cross-references and indexes, and the Web console offers an interactive knowledge-graph view. See [Personal Knowledge Base](https://docs.cowagent.ai/knowledge/index). + + + + + + +
+ Long-term Memory +

Long-term Memory · Three-tier architecture + Deep Dream

+
+ Personal Knowledge Base +

Knowledge Base · Auto-curated Markdown wiki

+
+ +
+ +## 🔧 Tools & Skills + +**Tools** are atomic capabilities the Agent uses to interact with system resources. **Skills** are higher-level workflows defined by a manifest file that compose multiple tools to accomplish complex tasks. + +### Tool System + +**Built-in tools** cover file I/O (`read` / `write` / `edit` / `ls`), terminal (`bash`), file sending (`send`), memory retrieval (`memory`), environment variables (`env_config`), web fetching (`web_fetch`), scheduling (`scheduler`), web search (`web_search`), vision (`vision`), and browser automation (`browser`). + +**MCP protocol** integrates the open ecosystem of [Model Context Protocol](https://modelcontextprotocol.io) servers. A single `mcp.json` is enough — supports stdio / SSE transports, hot reload, and zero-code integration. + +Learn more: [Tools overview](https://docs.cowagent.ai/tools/index) · [MCP integration](https://docs.cowagent.ai/tools/mcp). + +### Skills System + +- **[Skill Hub](https://skills.cowagent.ai/)** — open skill marketplace: browse, search, install in one click +- **GitHub / ClawHub / URL and more** — install skills from any source +- **Conversational authoring** — generate custom skills through dialogue with `skill-creator`; turn any workflow or third-party API into a reusable skill + +```bash +/skill list # list installed skills +/skill search # search the marketplace +/skill install # one-click install +``` + +Learn more: [Skills overview](https://docs.cowagent.ai/skills/index) · [Creating Skills](https://docs.cowagent.ai/skills/create). + +
+ +## 🏷 Changelog + +> **2026.07.08:** [v2.1.3](https://github.com/zhayujie/CowAgent/releases/tag/2.1.3) — [Desktop client](https://cowagent.ai/download/) for macOS / Windows, knowledge base document management, on-demand MCP tool retrieval, Traditional Chinese support, new models. + +> **2026.06.18:** [v2.1.2](https://github.com/zhayujie/CowAgent/releases/tag/2.1.2) — Web console upgrades (scheduled task management, knowledge base categories, multiple custom model providers), Self-Evolution improvements, new models (kimi-k2.7-code, glm-5.2), security hardening and refinements. + +> **2026.06.09:** [v2.1.1](https://github.com/zhayujie/CowAgent/releases/tag/2.1.1) — Self-Evolution, Web console upgrades (message management, parallel sessions), cross-platform MCP enhancements with concurrent calls, new models (MiniMax-M3, qwen3.7-plus), Python 3.13 support. + +> **2026.06.01:** [v2.1.0](https://github.com/zhayujie/CowAgent/releases/tag/2.1.0) — Internationalization, new channels (Telegram, Discord, Slack, WeChat Customer Service), CLI interaction upgrades, streamlined one-line install, MCP Streamable HTTP support, new models (claude-opus-4-8, MiMo). + +> **2026.05.22:** [v2.0.9](https://github.com/zhayujie/CowAgent/releases/tag/2.0.9) — Model management, MCP protocol support, persistent browser sessions, new models (gpt-5.5, gemini-3.5-flash, qwen3.7-max), deployment hardening. + +> **2026.05.06:** [v2.0.8](https://github.com/zhayujie/CowAgent/releases/tag/2.0.8) — Feishu channel overhaul (voice, streaming, QR onboarding), DeepSeek V4 and Baidu Qianfan support, scheduler tool upgrades. + +> **2026.04.22:** [v2.0.7](https://github.com/zhayujie/CowAgent/releases/tag/2.0.7) — Built-in image generation (GPT Image 2, Nano Banana), new models (Kimi K2.6, Claude Opus 4.7, GLM 5.1), memory and knowledge enhancements. + +> **2026.04.14:** [v2.0.6](https://github.com/zhayujie/CowAgent/releases/tag/2.0.6) — Knowledge base, Deep Dream memory distillation, smart context compression, multi-session Web console. + +> **2026.04.01:** [v2.0.5](https://github.com/zhayujie/CowAgent/releases/tag/2.0.5) — Cow CLI, Skill Hub open source, browser tool, WeCom Bot QR onboarding. + +> **2026.02.03:** [v2.0.0](https://github.com/zhayujie/CowAgent/releases/tag/2.0.0) — Major upgrade to a super Agent assistant with multi-step task planning, long-term memory, and the Skills framework. + +Full history: [Release Notes](https://docs.cowagent.ai/releases/overview) + +
+ +## 🤝 Community & Support + +[File an issue](https://github.com/zhayujie/CowAgent/issues) on GitHub, or scan the QR code below to join our WeChat community: + + + +
+ +## 🔗 Related Projects + +- **[Cow Skill Hub](https://github.com/zhayujie/cow-skill-hub)** — open skill marketplace for AI Agents; works with CowAgent, OpenClaw, Claude Code, and more +- **[bot-on-anything](https://github.com/zhayujie/bot-on-anything)** — lightweight LLM application framework with integrations for Slack, Telegram, Discord, Gmail, and more +- **[AgentMesh](https://github.com/MinimalFuture/AgentMesh)** — open-source multi-agent framework for solving complex problems through team collaboration + +
+ +## 🏢 Enterprise Services + +[**LinkAI**](https://link-ai.tech/) is an all-in-one AI Agent platform for enterprises and developers, offering managed hosting and enterprise-grade support for CowAgent: + +- **🚀 Zero-deployment hosted runtime** — spin up a [CowAgent online assistant](https://link-ai.tech/cowagent/create) in under a minute, no server required +- **🧠 Agent infrastructure** — unified access to LLMs, knowledge bases, databases, skills, and workflows; plug-and-play building blocks that extend what CowAgent can do +- **🏢 Team & enterprise features** — workspaces, role-based access, audit logs, and private deployment for production use cases + +For enterprise inquiries: sales@simple-future.tech or [scan the QR code](https://cdn.link-ai.tech/consultant.jpg) to reach our team on WeChat. + +
+ +## 🛠️ Development & Contributing + +All kinds of contributions are welcome — new features, bug fixes, performance improvements, docs, or sharing your own skills on the [Skill Hub](https://skills.cowagent.ai/submit). See [CONTRIBUTING.md](/CONTRIBUTING.md) to get started, then open an Issue to discuss or send a PR directly. + +⭐ Star the project to show your support, and Watch → Custom → Releases to get notified of new versions. PRs and Issues are always welcome. + +## 🌟 Contributors + +![cow contributors](https://contrib.rocks/image?repo=zhayujie/CowAgent&max=1000) + +
+ +## ⚠️ Disclaimer + +1. This project is licensed under the [MIT License](/LICENSE) and is intended for technical research and learning. You are responsible for complying with applicable laws and regulations in your jurisdiction; the maintainers assume no liability for any consequences arising from use of this project. +2. **Cost & safety:** Agent mode consumes substantially more tokens than regular chat — pick models that balance quality and cost. The Agent has access to your local operating system, so only deploy it in trusted environments. +3. CowAgent is a pure open-source project and does not participate in, authorize, or issue any cryptocurrency. + +
+ +## 📌 Project Renaming Notice + +This project was previously named `chatgpt-on-wechat` and is now officially **CowAgent**. The old GitHub URL redirects automatically; existing users may optionally run `git remote set-url origin https://github.com/zhayujie/CowAgent.git` to update the local remote. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..2efa67b --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`zhayujie/CowAgent` +- 原始仓库:https://github.com/zhayujie/CowAgent +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/agent/chat/__init__.py b/agent/chat/__init__.py new file mode 100644 index 0000000..69a90a9 --- /dev/null +++ b/agent/chat/__init__.py @@ -0,0 +1,3 @@ +from agent.chat.service import ChatService + +__all__ = ["ChatService"] diff --git a/agent/chat/service.py b/agent/chat/service.py new file mode 100644 index 0000000..66e9179 --- /dev/null +++ b/agent/chat/service.py @@ -0,0 +1,374 @@ +""" +ChatService - Wraps the Agent stream execution to produce CHAT protocol chunks. + +Translates agent events (message_update, message_end, tool_execution_end, etc.) +into the CHAT socket protocol format (content chunks with segment_id, tool_calls chunks). +""" + +import time +from typing import Callable, Optional + +from common.log import logger + + +class ChatService: + """ + High-level service that runs an Agent for a given query and streams + the results as CHAT protocol chunks via a callback. + + Usage: + svc = ChatService(agent_bridge) + svc.run(query, session_id, send_chunk_fn) + """ + + def __init__(self, agent_bridge): + """ + :param agent_bridge: AgentBridge instance (manages agent lifecycle) + """ + self.agent_bridge = agent_bridge + + def run(self, query: str, session_id: str, send_chunk_fn: Callable[[dict], None], + channel_type: str = ""): + """ + Run the agent for *query* and stream results back via *send_chunk_fn*. + + The method blocks until the agent finishes. After it returns the SDK + will automatically send the final (streaming=false) message. + + :param query: user query text + :param session_id: session identifier for agent isolation + :param send_chunk_fn: callable(chunk_data: dict) to send a streaming chunk + :param channel_type: source channel (e.g. "web", "feishu") for persistence + """ + agent = self.agent_bridge.get_agent(session_id=session_id) + if agent is None: + raise RuntimeError("Failed to initialise agent for the session") + + # Pass context metadata to model for downstream API requests + if hasattr(agent, 'model'): + agent.model.channel_type = channel_type or "" + agent.model.session_id = session_id or "" + + # Build a context so context-aware tools (e.g. scheduler) can resolve the + # receiver/session. This streaming path bypasses agent_bridge.agent_reply, + # so the attach step that normally happens there must be done here too. + context = self._build_context(query, session_id, channel_type) + self._attach_context_aware_tools(agent, context) + + # Mark this session as mid-run so the self-evolution idle scan does not + # fire concurrently when a single turn runs longer than idle_minutes. + self._mark_run_active(agent, True) + + # State shared between the event callback and this method + state = _StreamState() + + def on_event(event: dict): + """Translate agent events into CHAT protocol chunks.""" + event_type = event.get("type") + data = event.get("data", {}) + + if event_type == "reasoning_update": + delta = data.get("delta", "") + if delta: + send_chunk_fn({ + "chunk_type": "reasoning", + "delta": delta, + "segment_id": state.segment_id, + }) + + elif event_type == "message_update": + # Incremental text delta + delta = data.get("delta", "") + if delta: + send_chunk_fn({ + "chunk_type": "content", + "delta": delta, + "segment_id": state.segment_id, + }) + + elif event_type == "message_end": + # A content segment finished. + tool_calls = data.get("tool_calls", []) + if tool_calls: + # After tool_calls are executed the next content will be + # a new segment; collect tool results until turn_end. + state.pending_tool_results = [] + + elif event_type == "file_to_send": + url = data.get("url") or "" + if url: + fname = data.get("file_name") or "file" + ft = data.get("file_type") or "file" + if ft == "image": + link = f"![{fname}]({url})" + else: + link = f"[{fname}]({url})" + send_chunk_fn({ + "chunk_type": "content", + "delta": "\n\n" + link + "\n\n", + "segment_id": state.segment_id, + }) + # Remove url so the model won't repeat it in its reply + data.pop("url", None) + + elif event_type == "tool_execution_start": + # Notify the client that a tool is about to run (with its input args) + tool_name = data.get("tool_name", "") + arguments = data.get("arguments", {}) + # Cache arguments keyed by tool_call_id so tool_execution_end can include them + tool_call_id = data.get("tool_call_id", tool_name) + state.pending_tool_arguments[tool_call_id] = arguments + send_chunk_fn({ + "chunk_type": "tool_start", + "tool": tool_name, + "arguments": arguments, + }) + + elif event_type == "tool_execution_end": + tool_name = data.get("tool_name", "") + tool_call_id = data.get("tool_call_id", tool_name) + # Retrieve cached arguments from the matching tool_execution_start event + arguments = state.pending_tool_arguments.pop(tool_call_id, data.get("arguments", {})) + result = data.get("result", "") + status = data.get("status", "unknown") + execution_time = data.get("execution_time", 0) + elapsed_str = f"{execution_time:.2f}s" + + # Serialise result to string if needed + if not isinstance(result, str): + import json + try: + result = json.dumps(result, ensure_ascii=False) + except Exception: + result = str(result) + + tool_info = { + "name": tool_name, + "arguments": arguments, + "result": result, + "status": status, + "elapsed": elapsed_str, + } + + if state.pending_tool_results is not None: + state.pending_tool_results.append(tool_info) + + elif event_type == "turn_end": + has_tool_calls = data.get("has_tool_calls", False) + if has_tool_calls and state.pending_tool_results: + # Flush collected tool results as a single tool_calls chunk + send_chunk_fn({ + "chunk_type": "tool_calls", + "tool_calls": state.pending_tool_results, + }) + state.pending_tool_results = None + # Next content belongs to a new segment + state.segment_id += 1 + + # Run the agent with our event callback --------------------------- + logger.info(f"[ChatService] Starting agent run: session={session_id}, query={query[:80]}") + + from config import conf + max_context_turns = conf().get("agent_max_context_turns", 20) + + # Get full system prompt with skills + full_system_prompt = agent.get_full_system_prompt() + + # Create a copy of messages for this execution + with agent.messages_lock: + messages_copy = agent.messages.copy() + original_length = len(agent.messages) + + from agent.protocol.agent_stream import AgentStreamExecutor + + # Register a cancel token so /cancel can abort this in-flight run. + # IM channels key on session_id (no per-turn request_id here). + from agent.protocol import get_cancel_registry + registry = get_cancel_registry() + cancel_event = registry.register(session_id, session_id=session_id) if session_id else None + + executor = AgentStreamExecutor( + agent=agent, + model=agent.model, + system_prompt=full_system_prompt, + tools=agent.tools, + max_turns=agent.max_steps, + on_event=on_event, + messages=messages_copy, + max_context_turns=max_context_turns, + cancel_event=cancel_event, + ) + + try: + response = executor.run_stream(query) + except Exception: + # If executor cleared messages (context overflow), sync back + if len(executor.messages) == 0: + with agent.messages_lock: + agent.messages.clear() + logger.info("[ChatService] Cleared agent message history after executor recovery") + raise + finally: + # Clear the mid-run flag so idle scans can review this session again. + self._mark_run_active(agent, False) + # Release cancel token to keep the registry bounded. + if session_id: + try: + registry.unregister(session_id) + except Exception: + pass + + # Sync executor messages back to agent (thread-safe). + # The executor may have trimmed context, making its list shorter than + # original_length. In that case we must replace entirely — just + # appending would leave stale pre-trim messages in agent.messages + # and cause the same trim to fire on every subsequent request. + with agent.messages_lock: + trimmed = len(executor.messages) < original_length + if trimmed: + # Context was trimmed: the executor appended the new user + # query *before* trimming, so the new messages (user + + # assistant + tools) sit at the tail of the trimmed list. + # We cannot simply slice at original_length (it exceeds the + # list length). Instead, count how many messages the + # executor added on top of the post-trim baseline. + # + # Timeline inside executor.run_stream: + # 1. messages had `original_length` items + # 2. append user query → original_length + 1 + # 3. _trim_messages() → some smaller number (includes the + # user query because it belongs to the last turn) + # 4. LLM replies / tool calls appended + # + # The user query message is always the first message of the + # last turn (it cannot be trimmed away), so we locate it to + # find where "new" messages begin. + new_start = original_length # fallback + for idx in range(len(executor.messages) - 1, -1, -1): + msg = executor.messages[idx] + if msg.get("role") == "user": + content = msg.get("content", []) + is_user_query = False + if isinstance(content, list): + has_text = any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ) + is_user_query = has_text and not has_tool_result + elif isinstance(content, str): + is_user_query = True + if is_user_query: + new_start = idx + break + new_messages = list(executor.messages[new_start:]) + else: + new_messages = list(executor.messages[original_length:]) + agent.messages = list(executor.messages) + + # Persist new messages to SQLite so they survive restarts and + # can be queried via the HISTORY interface. + if new_messages: + self._persist_messages(session_id, list(new_messages), channel_type) + + # Store executor reference for files_to_send access + agent.stream_executor = executor + + # Execute post-process tools + agent._execute_post_process_tools() + + # Record this user turn for the self-evolution idle trigger. This + # streaming path bypasses agent_bridge.agent_reply, so the activity must + # be noted here, otherwise idle scans never see any signal to evolve. + self._note_evolution_turn(agent, context) + + logger.info(f"[ChatService] Agent run completed: session={session_id}") + + + + @staticmethod + def _build_context(query: str, session_id: str, channel_type: str): + """Build a Context for tool resolution on the streaming chat path. + + receiver falls back to session_id; the scheduler's delivery keys on + session_id as the receiver. + """ + from bridge.context import Context, ContextType + # Pass an explicit kwargs dict: Context's default kwargs is a shared + # mutable default, so omitting it would leak fields across sessions. + ctx = Context(ContextType.TEXT, query, kwargs={}) + ctx["session_id"] = session_id + ctx["receiver"] = session_id + ctx["isgroup"] = False + ctx["channel_type"] = channel_type or "" + return ctx + + @staticmethod + def _attach_context_aware_tools(agent, context): + """Attach the current context to tools that need it (scheduler).""" + try: + if not (context and getattr(agent, "tools", None)): + return + for tool in agent.tools: + if tool.name == "scheduler": + from agent.tools.scheduler.integration import attach_scheduler_to_tool + attach_scheduler_to_tool(tool, context) + break + except Exception as e: + logger.warning(f"[ChatService] Failed to attach context to scheduler: {e}") + + @staticmethod + def _mark_run_active(agent, active): + """Toggle the self-evolution mid-run flag for this session's agent.""" + try: + from agent.evolution.trigger import mark_run_active + mark_run_active(agent, active) + except Exception: + pass + + @staticmethod + def _note_evolution_turn(agent, context): + """Record a user turn so the self-evolution idle trigger has signal.""" + try: + from agent.evolution.trigger import note_user_turn + ch = (context.get("channel_type") or "") if context else "" + rcv = (context.get("receiver") or "") if context else "" + is_group = bool(context.get("isgroup")) if context else False + # Only single chats get a proactive push target; group push is noisy. + note_user_turn(agent, channel_type=ch, receiver=(rcv if not is_group else "")) + except Exception: + pass + + @staticmethod + def _persist_messages(session_id: str, new_messages: list, channel_type: str = ""): + try: + from config import conf + if not conf().get("conversation_persistence", True): + return + except Exception: + pass + try: + from agent.memory import get_conversation_store + get_conversation_store().append_messages( + session_id, new_messages, channel_type=channel_type + ) + except Exception as e: + logger.warning( + f"[ChatService] Failed to persist messages for session={session_id}: {e}" + ) + + +class _StreamState: + """Mutable state shared between the event callback and the run method.""" + + def __init__(self): + self.segment_id: int = 0 + # None means we are not accumulating tool results right now. + # A list means we are in the middle of a tool-execution phase. + self.pending_tool_results: Optional[list] = None + # Maps tool_call_id -> arguments captured from tool_execution_start, + # so that tool_execution_end can attach the correct input args. + self.pending_tool_arguments: dict = {} diff --git a/agent/chat/session_service.py b/agent/chat/session_service.py new file mode 100644 index 0000000..86bf153 --- /dev/null +++ b/agent/chat/session_service.py @@ -0,0 +1,241 @@ +""" +SessionService - Manages multi-session lifecycle for both web channel and cloud client. + +Provides a unified interface for listing, deleting, renaming, clearing context, +and generating AI titles for conversation sessions. Backed by ConversationStore +(SQLite) and AgentBridge (in-memory agent instances). +""" + +import re +from typing import Optional + +from common.log import logger + + +def _truncate_fallback_title(user_message: str, max_len: int = 30) -> str: + """Pick the first non-empty line of the user message and truncate it.""" + if not user_message: + return "New Chat" + first_line = "" + for line in user_message.splitlines(): + line = line.strip() + if line: + first_line = line + break + if not first_line: + return "New Chat" + if len(first_line) > max_len: + first_line = first_line[:max_len].rstrip() + "..." + return first_line + + +def generate_session_title(user_message: str, assistant_reply: str = "") -> str: + """ + Generate a short session title by calling the current bot's reply_text. + Falls back to the first line of the user message if the LLM call fails + or returns an obvious error sentinel. + """ + fallback = _truncate_fallback_title(user_message) + try: + from bridge.bridge import Bridge + from models.session_manager import Session + bot = Bridge().get_bot("chat") + + prompt_parts = [f"User: {user_message[:300]}"] + if assistant_reply: + prompt_parts.append(f"Assistant: {assistant_reply[:300]}") + + session = Session("__title_gen__", system_prompt="") + session.messages = [ + {"role": "user", "content": ( + "Generate a very short title (max 15 characters for Chinese, max 6 words for English) " + "summarizing this conversation. Return ONLY the title text, nothing else.\n\n" + + "\n".join(prompt_parts) + )} + ] + + result = bot.reply_text(session) or {} + # When bots fail (network error, auth error, rate limit, etc.) they + # typically return completion_tokens=0 with a sentinel content like + # "请再问我一次吧" / "我现在有点累了". Treat that as failure. + completion_tokens = result.get("completion_tokens", 0) or 0 + raw = (result.get("content") or "").strip() + if completion_tokens <= 0: + logger.warning( + f"[SessionService] Title generation got empty completion " + f"(completion_tokens={completion_tokens}, content='{raw[:50]}'), " + f"using fallback") + return fallback + + title = re.sub(r'.*?', '', raw, flags=re.DOTALL).strip().strip('"\'') + logger.info(f"[SessionService] Title generation result: '{title}' (len={len(title)})") + if title and len(title) <= 50: + return title + except Exception as e: + logger.warning(f"[SessionService] Title generation failed: {e}") + return fallback + + +class SessionService: + """ + High-level service for session lifecycle management. + + Usage: + svc = SessionService() + result = svc.dispatch("list", {"channel_type": "web", "page": 1}) + """ + + def _get_store(self): + from agent.memory import get_conversation_store + return get_conversation_store() + + def _remove_agent(self, session_id: str): + """Remove the in-memory Agent instance for a session if it exists.""" + try: + from bridge.bridge import Bridge + ab = Bridge().get_agent_bridge() + if session_id in ab.agents: + del ab.agents[session_id] + logger.info(f"[SessionService] Removed agent instance: {session_id}") + except Exception: + pass + + @staticmethod + def _normalize_sid(session_id: str) -> str: + if session_id and not session_id.startswith("session_"): + return f"session_{session_id}" + return session_id + + # ------------------------------------------------------------------ + # actions + # ------------------------------------------------------------------ + def list_sessions(self, channel_type: Optional[str] = None, + page: int = 1, page_size: int = 50) -> dict: + store = self._get_store() + return store.list_sessions( + channel_type=channel_type, + page=page, + page_size=page_size, + ) + + def delete_session(self, session_id: str) -> None: + if not session_id: + raise ValueError("session_id required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + store.clear_session(session_id) + self._remove_agent(session_id) + logger.info(f"[SessionService] Session deleted: {session_id}") + + def rename_session(self, session_id: str, title: str) -> None: + if not session_id: + raise ValueError("session_id required") + if not title: + raise ValueError("title required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + found = store.rename_session(session_id, title) + if not found: + raise ValueError("session not found") + + def clear_context(self, session_id: str) -> int: + """ + Set context boundary. Returns the new context_start_seq value. + """ + if not session_id: + raise ValueError("session_id required") + session_id = self._normalize_sid(session_id) + + store = self._get_store() + new_seq = store.clear_context(session_id) + self._remove_agent(session_id) + return new_seq + + def gen_title(self, session_id: str, user_message: str, + assistant_reply: str = "") -> str: + """ + Generate an AI title and persist it. Returns the generated title. + """ + if not session_id: + raise ValueError("session_id required") + if not user_message: + raise ValueError("user_message required") + session_id = self._normalize_sid(session_id) + + title = generate_session_title(user_message, assistant_reply) + + store = self._get_store() + updated = store.rename_session(session_id, title) + logger.info(f"[SessionService] Title set: sid={session_id}, " + f"title='{title}', db_updated={updated}") + return title + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a session management action and return a protocol-compatible + response dict. + + Action names use a ``*_session`` / session-prefixed convention so they + can coexist with history actions (e.g. ``query``) on the same HISTORY + message channel without ambiguity. + + Supported actions: + - list_sessions: list sessions with pagination + - delete_session: delete a session + - rename_session: rename a session title + - clear_context: set context boundary + - generate_title: AI-generate a session title + + :param action: one of the above action names + :param payload: action-specific payload + :return: dict with action, code, message, payload + """ + payload = payload or {} + try: + if action == "list_sessions": + result = self.list_sessions( + channel_type=payload.get("channel_type"), + page=int(payload.get("page", 1)), + page_size=int(payload.get("page_size", 50)), + ) + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "delete_session": + self.delete_session(payload.get("session_id", "")) + return {"action": action, "code": 200, "message": "success", "payload": None} + + elif action == "rename_session": + self.rename_session( + payload.get("session_id", ""), + payload.get("title", "").strip(), + ) + return {"action": action, "code": 200, "message": "success", "payload": None} + + elif action == "clear_context": + new_seq = self.clear_context(payload.get("session_id", "")) + return {"action": action, "code": 200, "message": "success", + "payload": {"context_start_seq": new_seq}} + + elif action == "generate_title": + title = self.gen_title( + payload.get("session_id", ""), + payload.get("user_message", ""), + payload.get("assistant_reply", ""), + ) + return {"action": action, "code": 200, "message": "success", + "payload": {"title": title}} + + else: + return {"action": action, "code": 400, + "message": f"unknown action: {action}", "payload": None} + + except ValueError as e: + return {"action": action, "code": 400, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[SessionService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/evolution/__init__.py b/agent/evolution/__init__.py new file mode 100644 index 0000000..f9f665f --- /dev/null +++ b/agent/evolution/__init__.py @@ -0,0 +1,19 @@ +""" +Self-evolution subsystem for CowAgent. + +Runs a lightweight, isolated review pass after a conversation goes idle to +decide whether anything is worth durably learning (memory / skill) or whether +an unfinished task can be pushed forward. Conservative by design: most +conversations should produce no change at all. + +Public entry points: + from agent.evolution import get_evolution_config + from agent.evolution.trigger import start_evolution_trigger, note_user_turn +""" + +from agent.evolution.config import EvolutionConfig, get_evolution_config + +__all__ = [ + "EvolutionConfig", + "get_evolution_config", +] diff --git a/agent/evolution/backup.py b/agent/evolution/backup.py new file mode 100644 index 0000000..4f294b9 --- /dev/null +++ b/agent/evolution/backup.py @@ -0,0 +1,102 @@ +"""File backup / rollback support for self-evolution. + +Before the evolution agent edits MEMORY.md or a skill file, we snapshot the +current state into ``memory/.evolution_backups//`` so a later "undo" +can restore it. File-level restore only — simple and reliable. +""" + +from __future__ import annotations + +import json +import shutil +import time +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +_BACKUP_DIRNAME = ".evolution_backups" +_MANIFEST_NAME = "manifest.json" +# Keep only the most recent N backups to bound disk usage. +_MAX_BACKUPS = 10 + + +def _backups_root(workspace_dir: Path) -> Path: + return Path(workspace_dir) / "memory" / _BACKUP_DIRNAME + + +def create_backup(workspace_dir: Path, files: List[Path]) -> Optional[str]: + """Snapshot ``files`` (those that exist) under a new backup id. + + Returns the backup_id, or None when there is nothing to back up. + """ + existing = [Path(f) for f in files if Path(f).exists()] + if not existing: + return None + + backup_id = datetime.now().strftime("%Y%m%d-%H%M%S-") + str(int(time.time() * 1000) % 1000) + root = _backups_root(workspace_dir) + target = root / backup_id + try: + target.mkdir(parents=True, exist_ok=True) + ws = Path(workspace_dir) + manifest = [] + for idx, src in enumerate(existing): + # Store under a flat index plus the relative path so restore knows + # where it came from, even for nested skill files. + try: + rel = str(src.relative_to(ws)) + except ValueError: + rel = src.name + dst = target / f"{idx}.bak" + shutil.copy2(src, dst) + manifest.append({"rel": rel, "bak": f"{idx}.bak"}) + (target / _MANIFEST_NAME).write_text( + json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8" + ) + _prune_old_backups(root) + # Caller logs a combined backup+review line; keep this at debug. + logger.debug(f"[Evolution] Created backup {backup_id} ({len(manifest)} file(s))") + return backup_id + except Exception as e: + logger.warning(f"[Evolution] Failed to create backup: {e}") + return None + + +def restore_backup(workspace_dir: Path, backup_id: str) -> bool: + """Restore all files captured under ``backup_id``. Returns success.""" + if not backup_id: + return False + target = _backups_root(workspace_dir) / backup_id + manifest_path = target / _MANIFEST_NAME + if not manifest_path.exists(): + logger.warning(f"[Evolution] Backup not found: {backup_id}") + return False + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + ws = Path(workspace_dir) + for entry in manifest: + bak = target / entry["bak"] + dst = ws / entry["rel"] + if bak.exists(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(bak, dst) + logger.info(f"[Evolution] Restored backup {backup_id} ({len(manifest)} file(s))") + return True + except Exception as e: + logger.warning(f"[Evolution] Failed to restore backup {backup_id}: {e}") + return False + + +def _prune_old_backups(root: Path) -> None: + """Drop the oldest backups beyond _MAX_BACKUPS (sorted by name = chronological).""" + try: + dirs = sorted( + [d for d in root.iterdir() if d.is_dir()], + key=lambda p: p.name, + ) + for old in dirs[:-_MAX_BACKUPS]: + shutil.rmtree(old, ignore_errors=True) + except Exception as e: + logger.debug(f"[Evolution] Backup prune skipped: {e}") diff --git a/agent/evolution/config.py b/agent/evolution/config.py new file mode 100644 index 0000000..948876f --- /dev/null +++ b/agent/evolution/config.py @@ -0,0 +1,76 @@ +"""Configuration for the self-evolution subsystem. + +Reads flat ``self_evolution_*`` keys from config.json. All fields have safe +defaults so the feature degrades gracefully when keys are absent. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + + +# Defaults — conservative (see executor module docstring). Disabled by default +# until release; enable via ``self_evolution_enabled``. +DEFAULT_ENABLED = False +DEFAULT_IDLE_MINUTES = 10 +DEFAULT_MIN_TURNS = 6 +# Max review steps for the isolated evolution agent. Kept small (not exposed as +# config): the review is meant to be cheap and focused, not a long autonomous run. +DEFAULT_MAX_STEPS = 12 + + +@dataclass +class EvolutionConfig: + """Resolved self-evolution settings.""" + + enabled: bool = DEFAULT_ENABLED + idle_minutes: int = DEFAULT_IDLE_MINUTES + min_turns: int = DEFAULT_MIN_TURNS + max_steps: int = DEFAULT_MAX_STEPS + + @property + def idle_seconds(self) -> int: + return max(60, self.idle_minutes * 60) + + +def _as_bool(value: Any, fallback: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + v = value.strip().lower() + if v in ("true", "1", "yes", "on"): + return True + if v in ("false", "0", "no", "off"): + return False + return fallback + + +def _as_pos_int(value: Any, fallback: int) -> int: + try: + n = int(value) + return n if n > 0 else fallback + except (TypeError, ValueError): + return fallback + + +def get_evolution_config() -> EvolutionConfig: + """Build EvolutionConfig from the live config.json ``self_evolution_*`` keys.""" + try: + from config import conf + c = conf() + except Exception: + c = {} + + def _get(key, default): + try: + return c.get(key, default) + except Exception: + return default + + return EvolutionConfig( + enabled=_as_bool(_get("self_evolution_enabled", None), DEFAULT_ENABLED), + idle_minutes=_as_pos_int(_get("self_evolution_idle_minutes", None), DEFAULT_IDLE_MINUTES), + min_turns=_as_pos_int(_get("self_evolution_min_turns", None), DEFAULT_MIN_TURNS), + max_steps=DEFAULT_MAX_STEPS, + ) diff --git a/agent/evolution/executor.py b/agent/evolution/executor.py new file mode 100644 index 0000000..5c418f3 --- /dev/null +++ b/agent/evolution/executor.py @@ -0,0 +1,556 @@ +"""Self-evolution executor. + +Runs an isolated review agent over an idle conversation's transcript and, if a +clear signal is found, lets it edit memory / skills via a restricted toolset. +Conservative by design: most runs return ``[SILENT]`` and change nothing. + +Flow: + 1. Build a transcript from the session's new (since last pass) messages. + 2. Snapshot MEMORY.md + daily file + editable skills (for undo) -> backup_id. + 3. Run an isolated agent (same model, restricted tools, evolution prompt). + 4. If output is [SILENT], or no workspace file actually changed -> done. + 5. Otherwise -> record to the evolution log, inject an [EVOLUTION] note into + the user session (so the main agent can honor "undo"), and push the + summary to the user's channel. + +Reuses existing infrastructure (AgentBridge.create_agent, ToolManager, +remember_scheduled_output, channel_factory) rather than introducing a fork. +""" + +from __future__ import annotations + +import re +import threading +from datetime import datetime +from pathlib import Path +from typing import List, Optional + +from common.log import logger + +from agent.evolution.backup import create_backup +from agent.evolution.config import get_evolution_config +from agent.evolution.prompts import ( + EVOLUTION_MARKER, + EVOLUTION_SYSTEM_PROMPT, + SILENT_TOKEN, + build_review_user_message, +) +from agent.evolution.record import append_session_evolution + +# Tools the isolated evolution agent is allowed to use. Everything else is +# withheld so a review pass can only read context, run workspace scripts, and +# edit memory/skill files. bash is needed by skill-creator's init script and is +# confined to the workspace by _BashWorkspaceGuard. +_ALLOWED_TOOLS = {"read", "write", "edit", "ls", "bash", "memory_search", "memory_get"} + +# Cap concurrent evolution passes so a burst of idle sessions can't spawn many +# background model runs at once. Extra sessions simply wait for the next scan. +_MAX_CONCURRENT = 2 +_running_lock = threading.Lock() +_running_count = 0 + + +def _builtin_skill_names() -> set: + """Names of skills shipped with the product (project-root ``skills/``). + + These are protected: the evolution agent must never edit them, even though + a same-named copy exists in the workspace at runtime. The project dir is the + authoritative list of what counts as built-in. + """ + try: + # executor.py -> agent/evolution -> agent -> project root + project_root = Path(__file__).resolve().parents[2] + builtin_dir = project_root / "skills" + if not builtin_dir.is_dir(): + return set() + names = set() + for entry in builtin_dir.iterdir(): + if entry.is_dir() and not entry.name.startswith("."): + names.add(entry.name) + return names + except Exception: + return set() + + +def _build_transcript(messages: List[dict], max_chars: int = 12000) -> str: + """Render the session messages into a compact text transcript.""" + lines: List[str] = [] + for msg in messages: + role = msg.get("role", "") + if role not in ("user", "assistant"): + continue + content = msg.get("content", "") + text = _extract_text(content) + if not text.strip(): + continue + speaker = "User" if role == "user" else "Assistant" + lines.append(f"{speaker}: {text.strip()}") + transcript = "\n".join(lines) + # Keep the most RECENT context if oversized (tail is most relevant). + if len(transcript) > max_chars: + transcript = "...(earlier omitted)...\n" + transcript[-max_chars:] + return transcript + + +def _extract_text(content) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + return "" + + +def _select_tools(all_tools: list) -> list: + return [t for t in all_tools if getattr(t, "name", None) in _ALLOWED_TOOLS] + + +# Tools whose writes must be confined to the workspace during evolution. +_WRITE_TOOLS = {"write", "edit"} + + +class _WorkspaceWriteGuard: + """Wraps a write/edit tool so it can ONLY write inside the workspace. + + Hard engineering guard (not prompt-based): any write resolving outside the + workspace — e.g. the project's bundled ``skills/`` dir — is rejected. This + protects built-in skills regardless of what the model attempts. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Mirror the attributes the agent runtime reads off a tool. + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + # The agent runtime calls execute_tool (not execute); route it through + # our guarded execute so the path checks always run. + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded tool error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def execute(self, args): + path = (args.get("path") or "").strip() + if path: + try: + resolved = Path(self._inner._resolve_path(path)).resolve() + from agent.tools.base_tool import ToolResult + # Confine writes to the workspace. This protects the product's + # bundled skills (which live outside the workspace) from ever + # being modified, no matter what path the model attempts. + if self._ws not in resolved.parents and resolved != self._ws: + return ToolResult.fail( + "Error: evolution may only write inside the workspace; " + f"path '{path}' is outside and was blocked." + ) + except Exception: + pass + return self._inner.execute(args) + + +class _BashWorkspaceGuard: + """Wraps the bash tool so evolution can only run commands inside the + workspace. + + Evolution needs bash for skill-creator's init script, but it runs + unattended in the background, so a raw shell is too broad. This guard: + - forces the command to execute with cwd = workspace, + - rejects commands that reference an absolute path or ``..`` segment + pointing OUTSIDE the workspace (the common ways to escape it). + It is a coarse textual check, not a sandbox — paired with the model's + instruction to only run skill-creator scripts, it keeps writes local. + """ + + def __init__(self, inner, workspace_dir: str): + self._inner = inner + self._ws = Path(workspace_dir).resolve() + # Pin the shell's working directory to the workspace. + try: + self._inner.cwd = str(self._ws) + except Exception: + pass + self.name = inner.name + self.description = inner.description + self.params = inner.params + + def __getattr__(self, item): + return getattr(self._inner, item) + + def execute_tool(self, params): + try: + return self.execute(params) + except Exception as e: + logger.error(f"[Evolution] guarded bash error: {e}") + from agent.tools.base_tool import ToolResult + return ToolResult.fail(f"Error: {e}") + + def _escapes_workspace(self, command: str) -> bool: + # Absolute paths that are not under the workspace. + for tok in re.findall(r'(?:^|\s)(/[^\s\'";|&]+)', command): + try: + resolved = Path(tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + # Parent-dir traversal that climbs above the workspace. + for tok in re.findall(r'[^\s\'";|&]*\.\.[^\s\'";|&]*', command): + try: + resolved = (self._ws / tok).resolve() + except Exception: + continue + if self._ws != resolved and self._ws not in resolved.parents: + return True + return False + + def execute(self, args): + from agent.tools.base_tool import ToolResult + command = (args.get("command") or "").strip() + if command and self._escapes_workspace(command): + return ToolResult.fail( + "Error: evolution may only run commands inside the workspace; " + "this command references a path outside it and was blocked." + ) + return self._inner.execute(args) + + +def _guard_tools(tools: list, workspace_dir: str) -> list: + """Wrap write/edit/bash tools with workspace guards; leave others as-is.""" + guarded = [] + for t in tools: + name = getattr(t, "name", None) + if name in _WRITE_TOOLS: + guarded.append(_WorkspaceWriteGuard(t, workspace_dir)) + elif name == "bash": + guarded.append(_BashWorkspaceGuard(t, workspace_dir)) + else: + guarded.append(t) + return guarded + + +# Workspace subtrees worth watching for evolution-induced changes. AGENT.md is +# watched too: evolution may rarely refine the assistant's persona/style there. +_WATCH_SUBDIRS = ("MEMORY.md", "AGENT.md", "skills", "knowledge", "output") +# Subpaths under memory/ to ignore: evolution's own bookkeeping + the nightly +# dream diary, none of which count as a user-facing change signal. +_MEMORY_IGNORE = (".evolution_backups", "dreams", "evolution") +# Files the skill subsystem maintains automatically (the enable/disable index). +# Not an evolution result, so a rewrite must not count as a change signal. +_WATCH_IGNORE_NAMES = ("skills_config.json",) + + +def _workspace_snapshot(workspace_dir) -> dict: + """Map relative path -> (mtime, size) for watched files. Cheap, no reads.""" + ws = Path(workspace_dir) + snap: dict = {} + for name in _WATCH_SUBDIRS: + root = ws / name + if root.is_file(): + try: + st = root.stat() + snap[name] = (st.st_mtime, st.st_size) + except OSError: + pass + continue + if not root.is_dir(): + continue + for p in root.rglob("*"): + if not p.is_file(): + continue + if p.name in _WATCH_IGNORE_NAMES: + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + + # Watch the daily memory files (memory/*.md and per-user dailies) since + # evolution now records learnings there. Skip backups/dreams bookkeeping. + mem_dir = ws / "memory" + if mem_dir.is_dir(): + for p in mem_dir.rglob("*.md"): + rel_parts = p.relative_to(mem_dir).parts + if rel_parts and rel_parts[0] in _MEMORY_IGNORE: + continue + try: + st = p.stat() + snap[str(p.relative_to(ws))] = (st.st_mtime, st.st_size) + except OSError: + pass + return snap + + +def _workspace_changed(workspace_dir, pre: dict) -> bool: + """True if any watched file was added, removed, or modified since ``pre``.""" + return _workspace_snapshot(workspace_dir) != pre + + +def run_evolution_for_session( + agent_bridge, + session_id: str, + channel_type: str = "", + receiver: str = "", + user_id: Optional[str] = None, + idle_minutes: float = 0.0, +) -> bool: + """Run one evolution pass for a session. Returns True if it changed anything. + + Safe to call from a background thread. All failures are swallowed and + logged — evolution must never disrupt the main pipeline. + """ + cfg = get_evolution_config() + if not cfg.enabled: + return False + + # Concurrency gate: bound how many evolution passes run at once. + global _running_count + with _running_lock: + if _running_count >= _MAX_CONCURRENT: + logger.info( + f"[Evolution] busy ({_running_count}/{_MAX_CONCURRENT} running); " + f"skipping session={session_id} this scan" + ) + return False + _running_count += 1 + + try: + agent = agent_bridge.agents.get(session_id) or agent_bridge.default_agent + if not agent: + return False + + with agent.messages_lock: + all_messages = list(agent.messages) + total_msgs = len(all_messages) + # In-memory evolution cursor: only review messages added since the last + # pass so a long session doesn't re-judge (and re-write) old content. + # Stored on the agent instance; lost on restart (acceptable — at worst + # one redundant pass right after a restart, gated by the file-change + # check downstream so it won't double-write identical memory). + done = int(getattr(agent, "_evo_done_msg_count", 0)) + if done > total_msgs: + done = 0 # history was trimmed/reset; start fresh + new_messages = all_messages[done:] + transcript = _build_transcript(new_messages) + if not transcript.strip(): + # Routine no-op: the per-minute scan hits every idle session. Advance + # the cursor so we don't re-scan the same tail; no log (pure noise). + agent._evo_done_msg_count = total_msgs + return False + + logger.info( + f"[Evolution] ▶ Reviewing session={session_id} " + f"(idle {idle_minutes:.1f}min, {len(new_messages)} new/{total_msgs} msgs, " + f"~{len(transcript)} chars)" + ) + + # Resolve workspace + files to snapshot for undo. + from agent.memory.config import get_default_memory_config + mem_cfg = get_default_memory_config() + workspace_dir = mem_cfg.get_workspace() + if user_id: + memory_file = Path(workspace_dir) / "memory" / "users" / user_id / "MEMORY.md" + else: + memory_file = Path(workspace_dir) / "MEMORY.md" + skills_dir = mem_cfg.get_skills_dir() + + # Snapshot MEMORY.md + every NON-protected skill's SKILL.md. Protected + # built-in skills are excluded from backup because they must never be + # edited in the first place. + protected_names = _builtin_skill_names() + # Back up both MEMORY.md and today's daily file: evolution now writes to + # the daily file, but MEMORY.md is cheap to snapshot and keeps undo safe + # if the model ever edits it. + today_daily = Path(workspace_dir) / "memory" / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + if user_id: + today_daily = Path(workspace_dir) / "memory" / "users" / user_id / ( + datetime.now().strftime("%Y-%m-%d") + ".md" + ) + # AGENT.md (persona) is backed up too so a rare persona edit is undoable. + # Persona is workspace-global (not per-user): it always lives at the + # workspace root, regardless of user_id. + agent_file = Path(workspace_dir) / "AGENT.md" + backup_files = [Path(memory_file), today_daily, agent_file] + if skills_dir.exists(): + for skill_md in skills_dir.rglob("SKILL.md"): + # The skill dir is the SKILL.md's parent (or an ancestor for + # collections); guard by checking the immediate top-level dir. + try: + top = skill_md.relative_to(skills_dir).parts[0] + except (ValueError, IndexError): + continue + if top in protected_names: + continue + backup_files.append(skill_md) + backup_id = create_backup(workspace_dir, backup_files) + _backup_n = sum(1 for f in backup_files if Path(f).exists()) + + # Snapshot the whole workspace (path -> mtime/size) so we can reliably + # detect ANY file change — including new output files written when + # finishing an unfinished task, which are not in backup_files. + pre_snapshot = _workspace_snapshot(workspace_dir) + + # Build the isolated review agent: same model, restricted tools, with a + # hard guard that confines all writes to the workspace (protects the + # project's bundled skills from ever being modified). + review_tools = _guard_tools( + _select_tools(list(getattr(agent, "tools", []) or [])), + str(workspace_dir), + ) + review_agent = agent_bridge.create_agent( + system_prompt="", + tools=review_tools, + description="Self-evolution review agent", + max_steps=cfg.max_steps, + workspace_dir=str(workspace_dir), + skill_manager=getattr(agent, "skill_manager", None), + memory_manager=getattr(agent, "memory_manager", None), + enable_skills=True, + runtime_info=getattr(agent, "runtime_info", None), + ) + # Mark this as a restricted review agent so runtime MCP reconciliation + # (ToolManager.sync_mcp_into_agent) will NOT silently re-inject MCP tools + # that _select_tools()/_guard_tools() intentionally withheld. Without this + # flag the review boundary would be re-opened on the first LLM turn. + review_agent._evolution_restricted = True + # Reuse the live model so it follows the user's configured model. + review_agent.model = agent.model + # Inject the evolution task brief AFTER the full system prompt: the agent + # gets the full context (tools, workspace, user preferences, memory, time) + # AND its evolution-specific instructions on top, instead of one + # overwriting the other. + review_agent.extra_system_suffix = EVOLUTION_SYSTEM_PROMPT + + logger.info( + f"[Evolution] backup {backup_id} ({_backup_n} files) → running review agent" + ) + user_msg = build_review_user_message(transcript, protected_skills=list(protected_names)) + result = review_agent.run_stream(user_msg, clear_history=True) + result = (result or "").strip() + + # These messages are now reviewed; advance the cursor so the next pass + # only looks at messages added after this point (silent or not). + agent._evo_done_msg_count = total_msgs + + # Respect an explicit silent verdict: empty, exactly [SILENT], or text + # that STARTS with [SILENT] means the model chose to stay quiet. + if not result or result.startswith(SILENT_TOKEN): + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + + # Anti-nag backstop: if the model wrote a summary but actually changed no + # watched file, stay silent — never notify about work that didn't happen. + if not _workspace_changed(workspace_dir, pre_snapshot): + logger.info( + f"[Evolution] ✗ session={session_id}: text produced but no file " + f"changed — staying silent" + ) + return False + + # The model produced a real summary. Strip any stray [SILENT] tokens it + # left mid-text, then notify. + result = result.replace(SILENT_TOKEN, "").strip() + if not result: + logger.info(f"[Evolution] ✗ No change for session={session_id} ([SILENT])") + return False + + logger.info(f"[Evolution] ✓ session={session_id} evolved:\n{result}") + append_session_evolution(workspace_dir, result, backup_id=backup_id, user_id=user_id) + # Inject an [EVOLUTION] note so the main agent can honor "undo". + _inject_evolution_record(agent_bridge, session_id, channel_type, result, backup_id) + # The injection appended its own messages ([SCHEDULED]/[EVOLUTION]). + # Advance the cursor past them so the next scan does not treat + # evolution's own bookkeeping as new user content and re-trigger. + try: + with agent.messages_lock: + agent._evo_done_msg_count = len(agent.messages) + except Exception: + pass + + # Push the summary to the user's channel. The "did a file actually + # change" gate above is the only throttle we need: real evolutions are + # rare, so no extra opt-in switch or daily-count limit is required. + if channel_type and receiver: + _notify_user(channel_type, receiver, result) + + return True + + except Exception as e: + logger.warning(f"[Evolution] Run failed for session={session_id}: {e}") + return False + finally: + with _running_lock: + _running_count -= 1 + + +def _inject_evolution_record( + agent_bridge, session_id: str, channel_type: str, summary: str, backup_id: Optional[str] +) -> None: + """Add an [EVOLUTION] note to the user session so the main agent can undo.""" + try: + note = f"{EVOLUTION_MARKER} {summary}" + if backup_id: + note += f"\n(backup_id: {backup_id}; to undo, restore this backup)" + # Reuse the scheduler-output injection path: isolated execution, only a + # compact record lands in the user session. + agent_bridge.remember_scheduled_output( + session_id=session_id, + content=note, + channel_type=channel_type, + task_description="self-evolution", + ) + except Exception as e: + logger.debug(f"[Evolution] Failed to inject evolution record: {e}") + + +def _notify_user(channel_type: str, receiver: str, summary: str) -> None: + """Push the evolution summary to the user's channel as a new message.""" + try: + from bridge.context import Context, ContextType + from bridge.reply import Reply, ReplyType + from channel.channel_factory import create_channel + + context = Context(ContextType.TEXT, summary) + context["receiver"] = receiver + context["isgroup"] = False + context["session_id"] = receiver + # Channels that reply to an original message need msg=None for a fresh push. + if channel_type in ("feishu", "dingtalk", "wecom_bot", "qq"): + context["msg"] = None + if channel_type == "feishu": + context["receive_id_type"] = "open_id" + + channel = create_channel(channel_type) + if not channel: + return + + # Web is request-response: a background push needs a synthetic request_id + # plus a request->session mapping so the channel can route the message to + # the user's polling queue (same approach the scheduler uses). + if channel_type == "web": + import uuid + request_id = f"evolution_{uuid.uuid4().hex[:8]}" + context["request_id"] = request_id + if hasattr(channel, "request_to_session"): + channel.request_to_session[request_id] = receiver + + channel.send(Reply(ReplyType.TEXT, summary), context) + logger.info(f"[Evolution] Notified user via {channel_type}") + except Exception as e: + logger.warning(f"[Evolution] Failed to notify user: {e}") diff --git a/agent/evolution/prompts.py b/agent/evolution/prompts.py new file mode 100644 index 0000000..649495e --- /dev/null +++ b/agent/evolution/prompts.py @@ -0,0 +1,170 @@ +"""Prompts for the self-evolution review agent. + +The system prompt is intentionally English-only: it governs the agent's +internal reasoning and is more stable / cheaper to maintain in one language. +The user-facing summary the agent produces should follow the user's own +language (instructed at the end of the prompt). + +Design goals (see ref/hermes-agent background_review for inspiration): + - Default to doing NOTHING. Evolution is the exception, not the rule. + - Signal types: skill, unfinished task, memory, knowledge. + - An explicit "do NOT capture" list to avoid self-poisoning over time. + - Generic examples only — never bake in domain-specific business terms. +""" + +# Sentinel the agent emits when there is nothing worth evolving. +SILENT_TOKEN = "[SILENT]" + +# Marker prefix for the evolution record injected into the user session, so the +# main chat agent can recognize past evolutions and honor an "undo" request. +EVOLUTION_MARKER = "[EVOLUTION]" + + +EVOLUTION_SYSTEM_PROMPT = """You are a self-evolution review agent for an AI assistant. + +You are given a transcript of a conversation that just went idle. Your job is to +decide whether anything from it is worth durably learning so future +conversations go better — and if so, to make that change. + +# Top principle: default to doing NOTHING + +Most ordinary conversations need no evolution. Only act when there is a CLEAR +signal below. If there is none, reply with exactly `[SILENT]` and stop. Staying +silent is the normal, correct outcome — not a failure. + +Greetings, small talk, acknowledgements ("ok", "thanks", "got it"), and casual +chat are NOT signals. For these, output exactly `[SILENT]` immediately — do not +explore files, do not write a summary, do not be polite. Just `[SILENT]`. + +IMPORTANT: A summary is only allowed if you ACTUALLY made a file change via a +tool (write/edit) in this pass. If you did not change any file, you MUST output +exactly `[SILENT]` — never describe a change you only intended to make. + +# Signals worth acting on (act only if at least one clearly appears) + +SKILL and UNFINISHED TASK are your PRIMARY value — no other mechanism handles +them. When their signal is clear, act; do not be shy here. + +1. SKILL — two cases: + a) PATCH an existing skill: a skill used here showed a STRUCTURAL problem (a + missing step/section, a wrong or outdated detail, an error in its + content), or its OUTPUT repeatedly misses something the user flagged. Read + the relevant skill file under the skills directory and make a small + incremental edit so it never recurs. + b) CREATE a new skill: a clearly reusable, repeatable workflow emerged that + no existing skill covers and the user is likely to want again. Follow the + `skill-creator` skill's conventions (read its SKILL.md for the required + structure), then create `skills//SKILL.md` by WRITING the file + directly with the write tool — this is the simplest reliable path. (bash + is available and confined to the workspace if a helper script is truly + needed, but a direct write is preferred.) Only create when the workflow is + genuinely reusable — not for a one-off task. + + CRITICAL — fix the SOURCE, do not just remember the symptom: when the root + cause of a problem lives IN a skill file itself (its instructions, content, + or configuration are wrong/outdated), the correct action is to EDIT that + skill so the problem cannot recur. Recording the corrected fact in memory + does NOT prevent recurrence — only fixing the skill does. Never log "skill X + has wrong detail Y" as a memory note in place of editing skill X. + +2. UNFINISHED TASK — a specific deliverable you promised but didn't produce, + AND you already have everything needed to finish it. DO IT now with the + available tools and produce the result (e.g. write the file you said you'd + write). If key info is missing, or the task is merely waiting on the user's + reply/decision, do NOTHING and stay [SILENT] — do not nag or ping the user. + You only ever notify the user as a side effect of having actually done work. + +3. MEMORY — RARE, last resort. Default to writing NOTHING here. The main + assistant already writes memory during the chat, and a nightly pass plus + context-overflow saves are dedicated safety nets — so memory is almost always + already covered without you. Skip unless the main assistant clearly missed a + durable fact that belongs in no skill AND would visibly change future replies. + - MEMORY.md is the curated long-term index, auto-loaded into EVERY future + conversation. Treat it as precious: edit it in place to CORRECT a wrong + fact, or append a new durable preference/decision/lesson — but do so + SPARINGLY (a lasting fact, not a passing detail; the nightly pass handles + routine consolidation). + - For a NEW fact that is important but not yet clearly lasting, append ONE + short bullet to today's `memory/YYYY-MM-DD.md` instead. When unsure, the + daily file is the safe place — but first ask whether this really belongs + in a skill. + - PERSONA (AGENT.md) — EXTREMELY rare: only on an explicit, repeated signal + about the assistant's own identity/personality/style, make a small edit to + AGENT.md; never for user/world facts, and when in doubt do nothing. + - Keep it to ONE short bullet. Never write paragraphs, never re-summarize the + conversation, never copy what the main assistant already recorded. + - If it is already captured anywhere (check MEMORY.md AND the daily file + first), do NOTHING. + +4. KNOWLEDGE — only if the conversation produced durable, reusable reference + knowledge on a topic (the kind worth looking up again) that the main + assistant did NOT already save to `knowledge/`. Add or update the relevant + file there. Like memory, this is the exception: skip routine Q&A, and if the + topic is already covered in `knowledge/`, do NOTHING rather than duplicate. + +# Do NOT capture (these poison future behavior) + +- Environment failures: missing binaries, unset credentials, uninstalled + packages, "command not found". The user can fix these; they are not durable + rules. +- Negative claims about tools or features ("tool X does not work"). These + harden into refusals the agent cites against itself later. +- One-off task narratives (e.g. summarizing today's content). Not a class of + reusable work. +- Transient errors that resolved on retry within the conversation. + +# Execution constraints + +- Before changing memory or a skill, READ the current content first and make a + small INCREMENTAL edit. Never fabricate, never rewrite large sections. +- AVOID DUPLICATES. Before writing memory, READ both MEMORY.md AND today's + daily file `memory/YYYY-MM-DD.md`. If the fact/preference is already recorded + in EITHER (even if worded differently), do NOT add it again. The main + assistant likely already wrote it during the chat — only add what is + genuinely new or a correction not yet reflected anywhere. +- You may only edit files inside the workspace. Built-in skills shipped with + the product live outside it and are write-protected; do not try to edit them. +- Make at most the few edits the signals justify; do not go looking for work. + +# Output + +- Nothing worth evolving -> output exactly `[SILENT]` and nothing else. +- Otherwise, after performing the edits, output a short user-facing summary in + the SAME LANGUAGE the user speaks in the conversation transcript. Write it for an ordinary user, in plain + everyday words — NOT a developer report. No need to expose internal details + (file names/paths, system mechanics, etc.). Briefly speak directly TO the user, telling them that you just did a self-learning pass, + what you learned, and what you changed in THIS pass. Keep it clear and focused on the key changes (a few lines), and let + the user know they can undo it. +""" + + +def build_review_user_message(transcript: str, protected_skills: list = None) -> str: + """Wrap the conversation transcript as the review agent's user message. + + ``protected_skills`` lists skill names that must never be edited (built-in + skills shipped with the product). Surfaced so the agent avoids them. + """ + protected_note = "" + if protected_skills: + names = ", ".join(sorted(protected_skills)) + protected_note = ( + "\n\nPROTECTED skills (built-in — never edit these): " + f"{names}\n" + ) + try: + from common import i18n + lang_name = "中文" if i18n.is_zh() else "English" + except Exception: + lang_name = "中文" + return ( + "Here is the conversation transcript that just went idle. Review it per " + "your instructions. Acting is the exception: the main value is fixing or " + "creating a skill and finishing promised work. Memory and knowledge are " + "rare last resorts — stay [SILENT] unless there is a clear, durable signal " + "not already covered." + f"{protected_note}\n" + f"The summary should preferably be written in: {lang_name}\n" + "\n" + f"{transcript}\n" + "" + ) diff --git a/agent/evolution/record.py b/agent/evolution/record.py new file mode 100644 index 0000000..751e3a8 --- /dev/null +++ b/agent/evolution/record.py @@ -0,0 +1,55 @@ +"""Self-evolution record log. + +Session-level evolutions are appended to their OWN per-day file under +``memory/evolution/YYYY-MM-DD.md`` (separate from the nightly Deep Dream diary +in ``memory/dreams/``). Each day's file accumulates one short section per +evolution pass — tagged with a timestamp and a backup id for undo — so the +memory UI can surface "what the agent learned/changed today" on one timeline +without ever mixing into the dream diary or the main conversation memory. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Optional + +from common.log import logger + + +def _evolution_dir(workspace_dir: Path, user_id: Optional[str] = None) -> Path: + base = Path(workspace_dir) / "memory" + if user_id: + return base / "users" / user_id / "evolution" + return base / "evolution" + + +def append_session_evolution( + workspace_dir: Path, + summary: str, + backup_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> None: + """Append a session-evolution entry to today's evolution log.""" + if not summary or not summary.strip(): + return + try: + evo_dir = _evolution_dir(workspace_dir, user_id) + evo_dir.mkdir(parents=True, exist_ok=True) + today = datetime.now().strftime("%Y-%m-%d") + log_file = evo_dir / f"{today}.md" + + ts = datetime.now().strftime("%H:%M") + header = f"## {ts}" + body = summary.strip() + if backup_id: + body += f"\n\n_backup_id: {backup_id}_" + + # Create with a title if the file is new, otherwise append a section. + if not log_file.exists(): + log_file.write_text(f"# Self-Evolution: {today}\n\n", encoding="utf-8") + with open(log_file, "a", encoding="utf-8") as f: + f.write(f"\n{header}\n\n{body}\n") + logger.info(f"[Evolution] Recorded session evolution to {log_file.name}") + except Exception as e: + logger.warning(f"[Evolution] Failed to record session evolution: {e}") diff --git a/agent/evolution/trigger.py b/agent/evolution/trigger.py new file mode 100644 index 0000000..e044339 --- /dev/null +++ b/agent/evolution/trigger.py @@ -0,0 +1,151 @@ +"""Idle-based evolution trigger. + +A single background thread periodically scans live agent sessions and runs an +evolution pass for any session that is idle for >= idle_minutes AND has enough +accumulated signal, where "enough signal" is EITHER: + - >= min_turns user turns since the last evolution, OR + - the live context has grown past _CONTEXT_RATIO of the agent's token budget + (mirrors how OpenClacky / Claude Code consolidate under context pressure). + +Turn counting is per user turn (not per message), measured from the last +evolution (or session start). After a pass runs, the baseline resets so a long +session can evolve multiple times without re-judging old content. + +Per-session evolution state is stored on the agent instance via lightweight +attributes set by AgentBridge.agent_reply (see _note_user_turn). +""" + +from __future__ import annotations + +import threading +import time + +from common.log import logger + +from agent.evolution.config import get_evolution_config +from agent.evolution.executor import run_evolution_for_session + +_SCAN_INTERVAL_SECONDS = 60 + +# Context-pressure trigger: evolve once the live context exceeds this fraction +# of the agent's token budget, even if min_turns hasn't been reached. Kept as a +# module constant (not user config) for now. Fallback budget matches +# agent_initializer / config.py (agent_max_context_tokens default = 50000). +_CONTEXT_RATIO = 0.8 +_FALLBACK_CONTEXT_BUDGET = 50000 + + +def _context_pressure_reached(agent) -> bool: + """True if the agent's live context exceeds _CONTEXT_RATIO of its budget. + + Uses the agent's own (estimated) token accounting so behavior matches the + existing context-trimming path. Best-effort: any error -> False. + """ + try: + with agent.messages_lock: + messages = list(agent.messages) + if not messages: + return False + est = sum(agent._estimate_message_tokens(m) for m in messages) + budget = getattr(agent, "max_context_tokens", None) or _FALLBACK_CONTEXT_BUDGET + return est / budget > _CONTEXT_RATIO + except Exception: + return False + + +def note_user_turn(agent, channel_type: str = "", receiver: str = "") -> None: + """Record activity for a session's agent. Called once per real user turn. + + Maintains, on the agent instance: + _evo_last_active : epoch seconds of the last user turn + _evo_turns : user turns since the last evolution + _evo_channel_type : originating channel (for later notify) + _evo_receiver : push target for notify + """ + try: + agent._evo_last_active = time.time() + agent._evo_turns = int(getattr(agent, "_evo_turns", 0)) + 1 + if channel_type: + agent._evo_channel_type = channel_type + if receiver: + agent._evo_receiver = receiver + except Exception: + pass + + +def mark_run_active(agent, active: bool) -> None: + """Flag whether the agent is mid-run, so idle scans skip a busy session. + + Without this, a single run that lasts longer than idle_minutes would let + the scanner fire an evolution pass concurrently with the live turn. + """ + try: + agent._evo_run_active = bool(active) + if active: + agent._evo_last_active = time.time() + except Exception: + pass + + +def start_evolution_trigger(agent_bridge) -> None: + """Start the idle-scan thread once per process (idempotent).""" + if getattr(agent_bridge, "_evolution_trigger_started", False): + return + agent_bridge._evolution_trigger_started = True + + t = threading.Thread( + target=_scan_loop, args=(agent_bridge,), daemon=True, name="evolution-trigger" + ) + t.start() + logger.info("[Evolution] Idle trigger started") + + +def _scan_loop(agent_bridge) -> None: + while True: + try: + time.sleep(_SCAN_INTERVAL_SECONDS) + cfg = get_evolution_config() + if not cfg.enabled: + continue + _scan_once(agent_bridge, cfg) + except Exception as e: + logger.warning(f"[Evolution] Scan loop error: {e}") + time.sleep(_SCAN_INTERVAL_SECONDS) + + +def _scan_once(agent_bridge, cfg) -> None: + now = time.time() + # Snapshot to avoid holding the dict while running long evolutions. + sessions = list(getattr(agent_bridge, "agents", {}).items()) + for session_id, agent in sessions: + try: + # Skip sessions whose agent is mid-run: a long turn must not be + # reviewed while it is still producing the answer. + if getattr(agent, "_evo_run_active", False): + continue + last_active = getattr(agent, "_evo_last_active", 0) + turns = int(getattr(agent, "_evo_turns", 0)) + # Enough signal = enough turns OR enough context pressure. + enough_signal = turns >= cfg.min_turns or _context_pressure_reached(agent) + if not enough_signal: + continue + idle = now - last_active if last_active > 0 else -1 + if last_active <= 0 or idle < cfg.idle_seconds: + continue + + channel_type = getattr(agent, "_evo_channel_type", "") or "" + receiver = getattr(agent, "_evo_receiver", "") or "" + + # Reset baseline BEFORE running so a long pass / new messages during + # it don't double-trigger; turns accrue fresh from here. + agent._evo_turns = 0 + + run_evolution_for_session( + agent_bridge, + session_id=session_id, + channel_type=channel_type, + receiver=receiver, + idle_minutes=(now - last_active) / 60 if last_active > 0 else 0.0, + ) + except Exception as e: + logger.warning(f"[Evolution] Failed to evaluate session={session_id}: {e}") diff --git a/agent/knowledge/__init__.py b/agent/knowledge/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/agent/knowledge/service.py b/agent/knowledge/service.py new file mode 100644 index 0000000..e404d56 --- /dev/null +++ b/agent/knowledge/service.py @@ -0,0 +1,645 @@ +""" +Knowledge service for handling knowledge base operations. + +Provides a unified interface for listing, reading, and graphing knowledge files, +callable from the web console, API, or CLI. + +Knowledge file layout (under workspace_root): + knowledge/index.md + knowledge/log.md + knowledge//.md +""" + +import os +import re +import asyncio +import shutil +import threading +from pathlib import Path +from typing import Optional, Iterable +from urllib.parse import quote + +from common.log import logger +from config import conf +from agent.memory.config import MemoryConfig +from agent.memory.manager import MemoryManager + + +class KnowledgeService: + """ + High-level service for knowledge base queries. + Operates directly on the filesystem. + """ + + PROTECTED_FILES = {"index.md", "log.md"} + INVALID_NAME_RE = re.compile(r'[<>:"|?*\x00-\x1f]') + IMPORT_EXTENSIONS = {".md", ".txt"} + MAX_IMPORT_FILES = 100 + MAX_IMPORT_FILE_SIZE = 10 * 1024 * 1024 + MAX_IMPORT_TOTAL_SIZE = 200 * 1024 * 1024 + + def __init__(self, workspace_root: str, memory_manager=None): + self.workspace_root = os.path.abspath(workspace_root) + self.knowledge_dir = os.path.join(self.workspace_root, "knowledge") + self._memory_manager = memory_manager + + def _resolve_path(self, rel_path: str, *, kind: Optional[str] = None, + allow_missing: bool = True) -> tuple: + if not isinstance(rel_path, str) or not rel_path.strip(): + raise ValueError("path is required") + rel_path = rel_path.replace("\\", "/").strip("/") + parts = rel_path.split("/") + if any(not p or p in (".", "..") or self.INVALID_NAME_RE.search(p) for p in parts): + raise ValueError("invalid path") + if kind == "document" and not rel_path.lower().endswith(".md"): + raise ValueError("document path must end with .md") + + root = Path(self.knowledge_dir).resolve() + candidate = root.joinpath(*parts) + # Resolve the nearest existing ancestor so a symlink cannot be used + # to escape when the final destination does not exist yet. + ancestor = candidate + while not ancestor.exists() and ancestor != root: + ancestor = ancestor.parent + try: + ancestor.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + if candidate.exists(): + try: + candidate.resolve().relative_to(root) + except ValueError: + raise ValueError("path outside knowledge dir") + elif not allow_missing: + raise FileNotFoundError(f"path not found: {rel_path}") + return rel_path, candidate + + def _ensure_not_protected(self, rel_path: str): + if rel_path in self.PROTECTED_FILES: + raise ValueError(f"protected knowledge file: {rel_path}") + + def _manager(self): + if self._memory_manager is None: + # Reuse the shared embedding provider selection so knowledge index + # sync gets vectors too, instead of degrading to keyword-only. + from agent.memory.embedding import create_default_embedding_provider + embedding_provider = create_default_embedding_provider() + self._memory_manager = MemoryManager( + MemoryConfig(workspace_root=self.workspace_root), + embedding_provider=embedding_provider, + ) + return self._memory_manager + + @staticmethod + def _run_sync(coro): + try: + asyncio.get_running_loop() + except RuntimeError: + return asyncio.run(coro) + result = [] + error = [] + + def runner(): + try: + result.append(asyncio.run(coro)) + except Exception as exc: + error.append(exc) + + thread = threading.Thread(target=runner) + thread.start() + thread.join() + if error: + raise error[0] + return result[0] if result else None + + def _sync_index(self, old_paths: Iterable[str], force: bool = False): + old_paths = sorted(set(old_paths)) + if not old_paths and not force: + return + manager = self._manager() + for rel_path in old_paths: + manager.storage.delete_by_path(f"knowledge/{rel_path}") + manager.mark_dirty() + self._run_sync(manager.sync()) + + @staticmethod + def _extract_title(md_path: Path, fallback: str) -> str: + """Read a markdown file's H1 title, falling back to the file stem.""" + try: + with open(md_path, "r", encoding="utf-8") as f: + for _ in range(20): + line = f.readline() + if not line: + break + stripped = line.strip() + if stripped.startswith("# "): + return stripped[2:].strip() or fallback + except Exception: + pass + return fallback + + def rebuild_index_md(self) -> bool: + """Regenerate knowledge/index.md from the actual directory tree. + + Keeps the index in sync with real files so it never drifts or loses + documents. Returns True when the file was (re)written. + """ + root = Path(self.knowledge_dir) + if not root.is_dir(): + return False + + def collect(dir_path: Path) -> list: + # Return sorted (rel_path, title) tuples for *.md under dir_path, + # excluding protected files at the knowledge root and dot files. + entries = [] + for md in sorted(dir_path.rglob("*.md")): + rel = md.relative_to(root).as_posix() + if any(part.startswith(".") for part in md.relative_to(root).parts): + continue + if rel in self.PROTECTED_FILES: + continue + entries.append((rel, self._extract_title(md, md.stem))) + return entries + + all_entries = collect(root) + + def link(rel: str) -> str: + # Encode each path segment so spaces / special chars stay valid in + # markdown links, while keeping the slashes between segments. + encoded = "/".join(quote(part) for part in rel.split("/")) + return f"./{encoded}" + + lines = ["# 知识库目录", ""] + # Root-level documents first (no category dir). + root_docs = [(rel, title) for rel, title in all_entries if "/" not in rel] + for rel, title in root_docs: + lines.append(f"- [{title}]({link(rel)})") + if root_docs: + lines.append("") + + # Group remaining documents by their top-level category. + categories = {} + for rel, title in all_entries: + if "/" not in rel: + continue + category = rel.split("/", 1)[0] + categories.setdefault(category, []).append((rel, title)) + + for category in sorted(categories.keys()): + lines.append(f"## {category}") + for rel, title in categories[category]: + lines.append(f"- [{title}]({link(rel)})") + lines.append("") + + content = "\n".join(lines).rstrip() + "\n" + index_path = root / "index.md" + try: + index_path.write_text(content, encoding="utf-8") + return True + except Exception as exc: + logger.warning(f"[KnowledgeService] Failed to rebuild index.md: {exc}") + return False + + def _sanitize_document_name(self, filename: str) -> str: + name = os.path.basename((filename or "").replace("\\", "/")).strip() + if not name: + raise ValueError("filename is required") + stem, ext = os.path.splitext(name) + if ext.lower() not in self.IMPORT_EXTENSIONS: + raise ValueError(f"unsupported file type: {ext or name}") + if not stem or stem in (".", "..") or self.INVALID_NAME_RE.search(stem): + raise ValueError("invalid filename") + safe_name = f"{stem}.md" + self._ensure_not_protected(safe_name) + return safe_name + + @staticmethod + def _decode_document_content(content) -> str: + if isinstance(content, str): + return content + if not isinstance(content, (bytes, bytearray)): + raise ValueError("document content is required") + return bytes(content).decode("utf-8-sig", errors="replace") + + def _resolve_import_destination(self, target_category: str, filename: str, + conflict_strategy: str) -> tuple: + target_rel, target_full = self._resolve_path(target_category, kind="category") + if not target_full.is_dir(): + raise FileNotFoundError(f"category not found: {target_rel}") + + safe_name = self._sanitize_document_name(filename) + destination = target_full / safe_name + rel_path = f"{target_rel}/{safe_name}" + + if destination.exists(): + if conflict_strategy == "skip": + return rel_path, destination, "skip" + if conflict_strategy == "rename": + stem = destination.stem + suffix = destination.suffix + for index in range(1, 1000): + candidate = target_full / f"{stem}-{index}{suffix}" + if not candidate.exists(): + candidate_rel = f"{target_rel}/{candidate.name}" + return candidate_rel, candidate, "write" + raise FileExistsError(f"target already exists: {rel_path}") + if conflict_strategy != "overwrite": + raise ValueError("invalid conflict strategy") + return rel_path, destination, "write" + + def create_document(self, path: str, content: str = "", overwrite: bool = False) -> dict: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if len((content or "").encode("utf-8")) > self.MAX_IMPORT_FILE_SIZE: + raise ValueError("file too large") + if full_path.exists() and not overwrite: + raise FileExistsError(f"target already exists: {rel_path}") + old_paths = [rel_path] if full_path.exists() else [] + full_path.parent.mkdir(parents=True, exist_ok=True) + full_path.write_text(content or "", encoding="utf-8") + # Keep index.md in sync before reindexing so it is indexed too. + self.rebuild_index_md() + self._sync_index(old_paths, force=True) + return {"path": rel_path, "created": True, "overwritten": bool(old_paths)} + + def import_documents(self, target_category: str, files: Iterable[dict], + conflict_strategy: str = "skip") -> dict: + if not isinstance(files, list): + raise ValueError("files must be a list") + if len(files) > self.MAX_IMPORT_FILES: + raise ValueError(f"too many files: max {self.MAX_IMPORT_FILES}") + results = [] + old_paths = [] + imported = skipped = failed = 0 + total_size = 0 + + for item in files: + filename = item.get("filename") if isinstance(item, dict) else None + try: + content_bytes = item.get("content") if isinstance(item, dict) else None + size = len(content_bytes.encode("utf-8")) if isinstance(content_bytes, str) else len(content_bytes or b"") + total_size += size + if total_size > self.MAX_IMPORT_TOTAL_SIZE: + raise ValueError("import batch too large") + if size > self.MAX_IMPORT_FILE_SIZE: + raise ValueError("file too large") + rel_path, destination, mode = self._resolve_import_destination( + target_category, filename, conflict_strategy + ) + if mode == "skip": + skipped += 1 + results.append({"filename": filename, "path": rel_path, "status": "skipped", + "reason": "target_exists"}) + continue + + old_exists = destination.exists() + content = self._decode_document_content(content_bytes) + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(content, encoding="utf-8") + if old_exists: + old_paths.append(rel_path) + imported += 1 + results.append({"filename": filename, "path": rel_path, "status": "imported", + "overwritten": old_exists}) + except Exception as exc: + failed += 1 + results.append({"filename": filename or "", "status": "failed", "reason": str(exc)}) + + if imported: + # Keep index.md in sync before reindexing so it is indexed too. + self.rebuild_index_md() + self._sync_index(old_paths, force=True) + return {"results": results, "imported": imported, "skipped": skipped, "failed": failed} + + def create_category(self, path: str) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if full_path.exists(): + return {"path": rel_path, "created": False, "reason": "already_exists"} + full_path.mkdir(parents=True) + return {"path": rel_path, "created": True} + + def rename_category(self, path: str, new_path: str) -> dict: + old_rel, old_full = self._resolve_path(path, kind="category", allow_missing=False) + new_rel, new_full = self._resolve_path(new_path, kind="category") + if not old_full.is_dir(): + raise ValueError(f"not a category: {old_rel}") + if new_full.exists(): + raise FileExistsError(f"target already exists: {new_rel}") + old_documents = [str(p.relative_to(old_full)).replace(os.sep, "/") + for p in old_full.rglob("*.md") if p.is_file()] + new_full.parent.mkdir(parents=True, exist_ok=True) + try: + old_full.rename(new_full) + except FileNotFoundError: + return {"old_path": old_rel, "path": new_rel, "moved": False, "reason": "not_found"} + except FileExistsError: + raise FileExistsError(f"target already exists: {new_rel}") + old_paths = [f"{old_rel}/{p}" for p in old_documents] + self._sync_index(old_paths) + return {"old_path": old_rel, "path": new_rel, "moved_documents": len(old_documents)} + + def delete_category(self, path: str, confirm: bool = False) -> dict: + rel_path, full_path = self._resolve_path(path, kind="category") + if not full_path.exists(): + return {"path": rel_path, "deleted": False, "reason": "not_found"} + if not full_path.is_dir(): + raise ValueError(f"not a category: {rel_path}") + knowledge_root = Path(self.knowledge_dir).resolve() + documents = [str(p.relative_to(knowledge_root)).replace(os.sep, "/") + for p in full_path.rglob("*.md") if p.is_file()] + if any(p in self.PROTECTED_FILES for p in documents): + raise ValueError("category contains protected knowledge files") + if any(full_path.iterdir()) and not confirm: + raise ValueError("category is not empty; confirmation is required") + try: + shutil.rmtree(full_path) + except FileNotFoundError: + return {"path": rel_path, "deleted": False, "reason": "not_found"} + self._sync_index(documents) + return {"path": rel_path, "deleted": True, "deleted_documents": len(documents)} + + def delete_documents(self, paths: Iterable[str]) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + results = [] + deleted = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + continue + if not full_path.is_file(): + raise ValueError(f"not a document: {rel_path}") + try: + full_path.unlink() + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": True}) + except FileNotFoundError: + deleted.append(rel_path) + results.append({"path": rel_path, "deleted": False, "reason": "not_found"}) + self._sync_index(deleted) + return {"results": results, "deleted": sum(1 for item in results if item["deleted"])} + + def move_documents(self, paths: Iterable[str], target_category: str) -> dict: + if not isinstance(paths, list): + raise ValueError("paths must be a list") + target_rel, target_full = self._resolve_path(target_category, kind="category") + if not target_full.is_dir(): + raise FileNotFoundError(f"category not found: {target_rel}") + results = [] + moved_old_paths = [] + for path in paths: + rel_path, full_path = self._resolve_path(path, kind="document") + self._ensure_not_protected(rel_path) + if not full_path.exists(): + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + continue + destination = target_full / full_path.name + new_rel = str(destination.relative_to(Path(self.knowledge_dir).resolve())).replace(os.sep, "/") + if destination.exists(): + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + continue + try: + os.link(full_path, destination) + full_path.unlink() + moved_old_paths.append(rel_path) + results.append({"path": rel_path, "moved": True, "target": new_rel}) + except FileExistsError: + results.append({"path": rel_path, "moved": False, "reason": "target_exists", + "target": new_rel}) + except FileNotFoundError: + results.append({"path": rel_path, "moved": False, "reason": "not_found"}) + self._sync_index(moved_old_paths) + return {"results": results, "moved": len(moved_old_paths)} + + # ------------------------------------------------------------------ + # list — directory tree with stats + # ------------------------------------------------------------------ + def list_tree(self) -> dict: + """ + Return the knowledge directory tree grouped by category, + supporting arbitrarily nested sub-directories. + + Returns:: + + { + "tree": [ + { + "dir": "concepts", + "files": [ + {"name": "moe.md", "title": "MoE", "size": 1234}, + ], + "children": [] + }, + { + "dir": "platform", + "files": [], + "children": [ + { + "dir": "analysis", + "files": [{"name": "perf.md", ...}], + "children": [] + } + ] + }, + ], + "stats": {"pages": 15, "size": 32768}, + "enabled": true + } + """ + if not os.path.isdir(self.knowledge_dir): + return {"tree": [], "stats": {"pages": 0, "size": 0}, "enabled": conf().get("knowledge", True)} + + stats = {"pages": 0, "size": 0} + root_files, tree = self._scan_dir(self.knowledge_dir, stats, is_root=True) + + return { + "root_files": root_files, + "tree": tree, + "stats": stats, + "enabled": conf().get("knowledge", True), + } + + def _scan_dir(self, dir_path: str, stats: dict, is_root: bool = False) -> tuple: + """ + Recursively scan a directory. + + :return: (files, children) where files is a list of .md file dicts + in this directory and children is a list of sub-directory nodes. + """ + files = [] + children = [] + for name in sorted(os.listdir(dir_path)): + if name.startswith("."): + continue + full = os.path.join(dir_path, name) + if os.path.isdir(full): + sub_files, sub_children = self._scan_dir(full, stats) + children.append({"dir": name, "files": sub_files, "children": sub_children}) + elif name.endswith(".md"): + size = os.path.getsize(full) + if not is_root: + stats["pages"] += 1 + stats["size"] += size + # Prefer the H1 heading as a readable title for normal docs. + # System files (index.md / log.md) keep their filename so the + # tree never hides what they actually are. + title = name[:-3] + if name not in self.PROTECTED_FILES: + try: + with open(full, "r", encoding="utf-8") as f: + first_line = f.readline().strip() + if first_line.startswith("# "): + title = first_line[2:].strip() or title + except Exception: + pass + files.append({"name": name, "title": title, "size": size}) + return files, children + + # ------------------------------------------------------------------ + # read — single file content + # ------------------------------------------------------------------ + def read_file(self, rel_path: str) -> dict: + """ + Read a single knowledge markdown file. + + :param rel_path: Relative path within knowledge/, e.g. ``concepts/moe.md`` + :return: dict with ``content`` and ``path`` + :raises ValueError: if path is invalid or escapes knowledge dir + :raises FileNotFoundError: if file does not exist + """ + rel_path, full_path = self._resolve_path(rel_path, kind="document") + if not full_path.is_file(): + raise FileNotFoundError(f"file not found: {rel_path}") + + with open(full_path, "r", encoding="utf-8") as f: + content = f.read() + return {"content": content, "path": rel_path} + + # ------------------------------------------------------------------ + # graph — nodes and links for visualization + # ------------------------------------------------------------------ + def build_graph(self) -> dict: + """ + Parse all knowledge pages and extract cross-reference links. + + Returns:: + + { + "nodes": [ + {"id": "concepts/moe.md", "label": "MoE", "category": "concepts"}, + ... + ], + "links": [ + {"source": "concepts/moe.md", "target": "entities/deepseek.md"}, + ... + ] + } + """ + knowledge_path = Path(self.knowledge_dir) + if not knowledge_path.is_dir(): + return {"nodes": [], "links": []} + + nodes = {} + links = [] + link_re = re.compile(r'\[([^\]]*)\]\(([^)]+\.md)\)') + + for md_file in knowledge_path.rglob("*.md"): + rel = str(md_file.relative_to(knowledge_path)) + if rel in ("index.md", "log.md"): + continue + parts = rel.split("/") + category = parts[0] if len(parts) > 1 else "root" + title = md_file.stem.replace("-", " ").title() + try: + content = md_file.read_text(encoding="utf-8") + first_line = content.strip().split("\n")[0] + if first_line.startswith("# "): + title = first_line[2:].strip() + for _, link_target in link_re.findall(content): + resolved = (md_file.parent / link_target).resolve() + try: + target_rel = str(resolved.relative_to(knowledge_path)) + except ValueError: + continue + if target_rel != rel: + links.append({"source": rel, "target": target_rel}) + except Exception: + pass + nodes[rel] = {"id": rel, "label": title, "category": category} + + valid_ids = set(nodes.keys()) + links = [l for l in links if l["source"] in valid_ids and l["target"] in valid_ids] + seen = set() + deduped = [] + for l in links: + key = tuple(sorted([l["source"], l["target"]])) + if key not in seen: + seen.add(key) + deduped.append(l) + + return {"nodes": list(nodes.values()), "links": deduped} + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a knowledge management action. + + :param action: ``list``, ``read``, or ``graph`` + :param payload: action-specific payload + :return: protocol-compatible response dict + """ + payload = payload or {} + try: + if action == "list": + result = self.list_tree() + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "read": + path = payload.get("path") + if not path: + return {"action": action, "code": 400, "message": "path is required", "payload": None} + result = self.read_file(path) + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "graph": + result = self.build_graph() + return {"action": action, "code": 200, "message": "success", "payload": result} + + elif action == "create_category": + result = self.create_category(payload.get("path")) + elif action == "rename_category": + result = self.rename_category(payload.get("path"), payload.get("new_path")) + elif action == "delete_category": + result = self.delete_category(payload.get("path"), payload.get("confirm", False)) + elif action == "delete_documents": + result = self.delete_documents(payload.get("paths") or []) + elif action == "move_documents": + result = self.move_documents(payload.get("paths") or [], payload.get("target_category")) + elif action == "create_document": + result = self.create_document(payload.get("path"), payload.get("content", ""), + payload.get("overwrite", False)) + elif action == "import_documents": + result = self.import_documents( + payload.get("target_category"), + payload.get("files") or [], + payload.get("conflict_strategy", "skip"), + ) + else: + return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + return {"action": action, "code": 200, "message": "success", "payload": result} + + except ValueError as e: + return {"action": action, "code": 403, "message": str(e), "payload": None} + except FileNotFoundError as e: + return {"action": action, "code": 404, "message": str(e), "payload": None} + except FileExistsError as e: + return {"action": action, "code": 409, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[KnowledgeService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} diff --git a/agent/memory/__init__.py b/agent/memory/__init__.py new file mode 100644 index 0000000..d08ffa8 --- /dev/null +++ b/agent/memory/__init__.py @@ -0,0 +1,24 @@ +""" +Memory module for AgentMesh + +Provides both long-term memory (vector/keyword search) and short-term +conversation history persistence (SQLite). +""" + +from agent.memory.manager import MemoryManager +from agent.memory.config import MemoryConfig, get_default_memory_config, set_global_memory_config +from agent.memory.embedding import create_embedding_provider, create_default_embedding_provider +from agent.memory.conversation_store import ConversationStore, get_conversation_store +from agent.memory.summarizer import ensure_daily_memory_file + +__all__ = [ + 'MemoryManager', + 'MemoryConfig', + 'get_default_memory_config', + 'set_global_memory_config', + 'create_embedding_provider', + 'create_default_embedding_provider', + 'ConversationStore', + 'get_conversation_store', + 'ensure_daily_memory_file', +] diff --git a/agent/memory/chunker.py b/agent/memory/chunker.py new file mode 100644 index 0000000..8dfd062 --- /dev/null +++ b/agent/memory/chunker.py @@ -0,0 +1,140 @@ +""" +Text chunking utilities for memory + +Splits text into chunks with token limits and overlap +""" + +from __future__ import annotations +from typing import List, Tuple +from dataclasses import dataclass + + +@dataclass +class TextChunk: + """Represents a text chunk with line numbers""" + text: str + start_line: int + end_line: int + + +class TextChunker: + """Chunks text by line count with token estimation""" + + def __init__(self, max_tokens: int = 500, overlap_tokens: int = 50): + """ + Initialize chunker + + Args: + max_tokens: Maximum tokens per chunk + overlap_tokens: Overlap tokens between chunks + """ + self.max_tokens = max_tokens + self.overlap_tokens = overlap_tokens + # Rough estimation: ~4 chars per token for English/Chinese mixed + self.chars_per_token = 4 + + def chunk_text(self, text: str) -> List[TextChunk]: + """ + Chunk text into overlapping segments + + Args: + text: Input text to chunk + + Returns: + List of TextChunk objects + """ + if not text.strip(): + return [] + + lines = text.split('\n') + chunks = [] + + max_chars = self.max_tokens * self.chars_per_token + overlap_chars = self.overlap_tokens * self.chars_per_token + + current_chunk = [] + current_chars = 0 + start_line = 1 + + for i, line in enumerate(lines, start=1): + line_chars = len(line) + + # If single line exceeds max, split it + if line_chars > max_chars: + # Save current chunk if exists + if current_chunk: + chunks.append(TextChunk( + text='\n'.join(current_chunk), + start_line=start_line, + end_line=i - 1 + )) + current_chunk = [] + current_chars = 0 + + # Split long line into multiple chunks + for sub_chunk in self._split_long_line(line, max_chars): + chunks.append(TextChunk( + text=sub_chunk, + start_line=i, + end_line=i + )) + + start_line = i + 1 + continue + + # Check if adding this line would exceed limit + if current_chars + line_chars > max_chars and current_chunk: + # Save current chunk + chunks.append(TextChunk( + text='\n'.join(current_chunk), + start_line=start_line, + end_line=i - 1 + )) + + # Start new chunk with overlap + overlap_lines = self._get_overlap_lines(current_chunk, overlap_chars) + current_chunk = overlap_lines + [line] + current_chars = sum(len(l) for l in current_chunk) + start_line = i - len(overlap_lines) + else: + # Add line to current chunk + current_chunk.append(line) + current_chars += line_chars + + # Save last chunk + if current_chunk: + chunks.append(TextChunk( + text='\n'.join(current_chunk), + start_line=start_line, + end_line=len(lines) + )) + + return chunks + + def _split_long_line(self, line: str, max_chars: int) -> List[str]: + """Split a single long line into multiple chunks""" + chunks = [] + for i in range(0, len(line), max_chars): + chunks.append(line[i:i + max_chars]) + return chunks + + def _get_overlap_lines(self, lines: List[str], target_chars: int) -> List[str]: + """Get last few lines that fit within target_chars for overlap""" + overlap = [] + chars = 0 + + for line in reversed(lines): + line_chars = len(line) + if chars + line_chars > target_chars: + break + overlap.insert(0, line) + chars += line_chars + + return overlap + + def chunk_markdown(self, text: str) -> List[TextChunk]: + """ + Chunk markdown text while respecting structure + (For future enhancement: respect markdown sections) + """ + return self.chunk_text(text) diff --git a/agent/memory/config.py b/agent/memory/config.py new file mode 100644 index 0000000..70d303d --- /dev/null +++ b/agent/memory/config.py @@ -0,0 +1,122 @@ +""" +Memory configuration module + +Provides global memory configuration with simplified workspace structure +""" + +from __future__ import annotations +import os +from dataclasses import dataclass, field +from typing import Optional, List +from pathlib import Path + + +def _default_workspace(): + """Get default workspace path with proper Windows support""" + from common.utils import expand_path + return expand_path("~/cow") + + +@dataclass +class MemoryConfig: + """Configuration for memory storage and search""" + + # Storage paths (default: ~/cow) + workspace_root: str = field(default_factory=_default_workspace) + + # Embedding config + embedding_provider: str = "openai" # "openai" | "local" + embedding_model: str = "text-embedding-3-small" + embedding_dim: int = 1536 + + # Chunking config + chunk_max_tokens: int = 500 + chunk_overlap_tokens: int = 50 + + # Search config + max_results: int = 10 + min_score: float = 0.1 + + # Hybrid search weights + vector_weight: float = 0.7 + keyword_weight: float = 0.3 + + # Memory sources + sources: List[str] = field(default_factory=lambda: ["memory", "session"]) + + # Sync config + enable_auto_sync: bool = True + sync_on_search: bool = True + + + def get_workspace(self) -> Path: + """Get workspace root directory""" + return Path(self.workspace_root) + + def get_memory_dir(self) -> Path: + """Get memory files directory""" + return self.get_workspace() / "memory" + + def get_db_path(self) -> Path: + """Get SQLite database path for long-term memory index""" + index_dir = self.get_memory_dir() / "long-term" + index_dir.mkdir(parents=True, exist_ok=True) + return index_dir / "index.db" + + def get_skills_dir(self) -> Path: + """Get skills directory""" + return self.get_workspace() / "skills" + + def get_agent_workspace(self, agent_name: Optional[str] = None) -> Path: + """ + Get workspace directory for an agent + + Args: + agent_name: Optional agent name (not used in current implementation) + + Returns: + Path to workspace directory + """ + workspace = self.get_workspace() + # Ensure workspace directory exists + workspace.mkdir(parents=True, exist_ok=True) + return workspace + + +# Global memory configuration +_global_memory_config: Optional[MemoryConfig] = None + + +def get_default_memory_config() -> MemoryConfig: + """ + Get the global memory configuration. + If not set, returns a default configuration. + + Returns: + MemoryConfig instance + """ + global _global_memory_config + if _global_memory_config is None: + _global_memory_config = MemoryConfig() + return _global_memory_config + + +def set_global_memory_config(config: MemoryConfig): + """ + Set the global memory configuration. + This should be called before creating any MemoryManager instances. + + Args: + config: MemoryConfig instance to use globally + + Example: + >>> from agent.memory import MemoryConfig, set_global_memory_config + >>> config = MemoryConfig( + ... workspace_root="~/my_agents", + ... embedding_provider="openai", + ... vector_weight=0.8 + ... ) + >>> set_global_memory_config(config) + """ + global _global_memory_config + _global_memory_config = config diff --git a/agent/memory/conversation_store.py b/agent/memory/conversation_store.py new file mode 100644 index 0000000..1537f98 --- /dev/null +++ b/agent/memory/conversation_store.py @@ -0,0 +1,1278 @@ +""" +Conversation history persistence using SQLite. + +Design: +- sessions table: per-session metadata (channel_type, last_active, msg_count) +- messages table: individual messages stored as JSON, append-only +- Pruning: age-based only (sessions not updated within N days are deleted) +- Thread-safe via a single in-process lock + +Storage path: ~/cow/sessions/conversations.db +""" + +from __future__ import annotations + +import json +import re +import sqlite3 +import threading +import time +from pathlib import Path +from typing import Any, Dict, List, Optional + +from common.log import logger + + +# --------------------------------------------------------------------------- +# Schema +# --------------------------------------------------------------------------- + +_DDL = """ +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + channel_type TEXT NOT NULL DEFAULT '', + title TEXT NOT NULL DEFAULT '', + context_start_seq INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + last_active INTEGER NOT NULL, + msg_count INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + seq INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + extras TEXT NOT NULL DEFAULT '', + UNIQUE (session_id, seq) +); + +CREATE INDEX IF NOT EXISTS idx_messages_session + ON messages (session_id, seq); + +CREATE INDEX IF NOT EXISTS idx_sessions_last_active + ON sessions (last_active); +""" + +# Migration: add channel_type column to existing databases that predate it. +_MIGRATION_ADD_CHANNEL_TYPE = """ +ALTER TABLE sessions ADD COLUMN channel_type TEXT NOT NULL DEFAULT ''; +""" + +_MIGRATION_ADD_TITLE = """ +ALTER TABLE sessions ADD COLUMN title TEXT NOT NULL DEFAULT ''; +""" + +_MIGRATION_ADD_CONTEXT_START_SEQ = """ +ALTER TABLE sessions ADD COLUMN context_start_seq INTEGER NOT NULL DEFAULT 0; +""" + +# Generic JSON sidecar for per-message attachments (TTS audio URL, future use). +# Always optional — readers must tolerate missing column / empty / invalid JSON. +_MIGRATION_ADD_MSG_EXTRAS = """ +ALTER TABLE messages ADD COLUMN extras TEXT NOT NULL DEFAULT ''; +""" + +DEFAULT_MAX_AGE_DAYS: int = 30 + + +def _is_visible_user_message(content: Any) -> bool: + """ + Return True when a user-role message represents actual user input + (not an internal tool_result injected by the agent loop). + """ + if isinstance(content, str): + return bool(content.strip()) + if isinstance(content, list): + return any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + return False + + +def _extract_display_text(content: Any) -> str: + """ + Extract the human-readable text portion from a message content value. + Returns an empty string for tool_use / tool_result blocks. + """ + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [ + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ] + return "\n".join(p for p in parts if p).strip() + return "" + + +# Internal markers written into the session for the agent's own bookkeeping +# (scheduler injection / self-evolution undo). They must stay in the stored +# content (the LLM reads them, e.g. to find a backup_id for undo) but should +# never be shown verbatim to the user in the chat history UI. +_SCHEDULED_DISPLAY_MARKERS = ("[SCHEDULED]", "Scheduled task") +_EVOLUTION_DISPLAY_MARKER = "[EVOLUTION]" + + +def _is_internal_user_marker(text: str) -> bool: + """True if a user-turn text is an internal injection marker (hide from UI).""" + t = (text or "").lstrip() + return any(t.startswith(m) for m in _SCHEDULED_DISPLAY_MARKERS) + + +def _is_evolution_text(text: str) -> bool: + """True if assistant text is a self-evolution summary (before cleaning).""" + return (text or "").lstrip().startswith(_EVOLUTION_DISPLAY_MARKER) + + +def _clean_display_text(text: str) -> str: + """Strip internal markers from assistant text for user-facing display. + + Removes a leading ``[EVOLUTION]`` tag and a trailing ``(backup_id: ...)`` + undo hint. The raw stored message is untouched, so undo + LLM context still + work; only the rendered chat bubble is cleaned. + """ + if not text: + return text + cleaned = text + stripped = cleaned.lstrip() + if stripped.startswith(_EVOLUTION_DISPLAY_MARKER): + cleaned = stripped[len(_EVOLUTION_DISPLAY_MARKER):].lstrip() + # Drop a trailing backup_id undo hint line, e.g. + # "(backup_id: 20260607-...; to undo, restore this backup)" + cleaned = re.sub( + r"\n*\(backup_id:[^\)]*\)\s*$", + "", + cleaned, + ).rstrip() + return cleaned + + +def _extract_tool_calls(content: Any) -> List[Dict[str, Any]]: + """ + Extract tool_use blocks from an assistant message content. + Returns a list of {name, arguments} dicts (result filled in later). + """ + if not isinstance(content, list): + return [] + return [ + {"id": b.get("id", ""), "name": b.get("name", ""), "arguments": b.get("input", {})} + for b in content + if isinstance(b, dict) and b.get("type") == "tool_use" + ] + + +def _extract_tool_results(content: Any) -> Dict[str, dict]: + """ + Extract tool_result blocks from a user message, keyed by tool_use_id. + Values are {"result": str, "is_error": bool}. + """ + if not isinstance(content, list): + return {} + results = {} + for b in content: + if not isinstance(b, dict) or b.get("type") != "tool_result": + continue + tool_id = b.get("tool_use_id", "") + result_content = b.get("content", "") + if isinstance(result_content, list): + result_content = "\n".join( + rb.get("text", "") for rb in result_content + if isinstance(rb, dict) and rb.get("type") == "text" + ) + results[tool_id] = {"result": str(result_content), "is_error": bool(b.get("is_error", False))} + return results + + +def _group_into_display_turns( + rows: List[tuple], + include_thinking: bool = True, +) -> List[Dict[str, Any]]: + """ + Convert raw (role, content_json, created_at) DB rows into display turns. + + One display turn = one visible user message + one merged assistant reply. + All intermediate assistant messages (those carrying tool_use) and the final + assistant text reply produced for the same user query are collapsed into a + single assistant turn, exactly matching the live SSE rendering where tools + and the final answer appear inside the same bubble. + + Grouping rules: + - A visible user message starts a new group. + - tool_result user messages are internal; their content is attached to the + matching tool_use entry via tool_use_id and they never become own turns. + - All assistant messages within a group are merged: + * tool_use blocks → tool_calls list (result filled from tool_results) + * text blocks → last non-empty text becomes the display content + """ + # ------------------------------------------------------------------ # + # Pass 1: split rows into groups, each starting with a visible user msg + # ------------------------------------------------------------------ # + # group = (user_row | None, [subsequent_rows]) + # user_row: (content, created_at) + groups: List[tuple] = [] + cur_user: Optional[tuple] = None + cur_rest: List[tuple] = [] + started = False + + for role, raw_content, created_at, raw_extras in rows: + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + try: + extras = json.loads(raw_extras) if raw_extras else {} + if not isinstance(extras, dict): + extras = {} + except Exception: + extras = {} + + if role == "user" and _is_visible_user_message(content): + if started: + groups.append((cur_user, cur_rest)) + cur_user = (content, created_at, extras) + cur_rest = [] + started = True + else: + cur_rest.append((role, content, created_at, extras)) + + if started: + groups.append((cur_user, cur_rest)) + + # ------------------------------------------------------------------ # + # Pass 2: build display turns from each group + # ------------------------------------------------------------------ # + turns: List[Dict[str, Any]] = [] + + for user_row, rest in groups: + # User turn + if user_row: + content, created_at, _u_extras = user_row + text = _extract_display_text(content) + # Hide internal injection markers (scheduler / self-evolution) so the + # user never sees a synthetic "[SCHEDULED] self-evolution" bubble; + # the assistant reply that follows is still rendered. + if text and not _is_internal_user_marker(text): + turns.append({"role": "user", "content": text, "created_at": created_at}) + + # Build an ordered list of steps preserving the original sequence: + # thinking → content → tool_call → content → ... + steps: List[Dict[str, Any]] = [] + tool_results: Dict[str, str] = {} + final_text = "" + final_ts: Optional[int] = None + merged_extras: Dict[str, Any] = {} + + for role, content, created_at, extras in rest: + if role == "assistant" and isinstance(extras, dict): + merged_extras.update(extras) + if role == "user": + tool_results.update(_extract_tool_results(content)) + elif role == "assistant": + # Walk content blocks in order to preserve interleaving + if isinstance(content, list): + for block in content: + if not isinstance(block, dict): + continue + btype = block.get("type") + if btype == "thinking": + if not include_thinking: + continue + txt = block.get("thinking", "").strip() + if txt: + steps.append({"type": "thinking", "content": txt}) + elif btype == "text": + txt = block.get("text", "").strip() + if txt: + steps.append({"type": "content", "content": txt}) + final_text = txt + elif btype == "tool_use": + steps.append({ + "type": "tool", + "id": block.get("id", ""), + "name": block.get("name", ""), + "arguments": block.get("input", {}), + }) + elif isinstance(content, str) and content.strip(): + steps.append({"type": "content", "content": content.strip()}) + final_text = content.strip() + final_ts = created_at + + # Attach tool results to tool steps + for step in steps: + if step["type"] == "tool": + tr = tool_results.get(step.get("id", ""), {}) + if not isinstance(tr, dict): + tr = {"result": tr} + step["result"] = tr.get("result", "") + step["is_error"] = tr.get("is_error", False) + + # Detect a self-evolution bubble BEFORE cleaning the marker away, so the + # UI can flag it even though the visible text stays clean. + is_evolution = _is_evolution_text(final_text) + + # Clean internal markers from the user-facing assistant text. Applies to + # both the final content and the mirrored content step so the rendered + # bubble shows clean text while the stored message keeps the markers. + final_text = _clean_display_text(final_text) + for step in steps: + if step.get("type") == "content": + step["content"] = _clean_display_text(step.get("content", "")) + + if steps or final_text: + turn = { + "role": "assistant", + "content": final_text, + "steps": steps, + "created_at": final_ts or (user_row[1] if user_row else 0), + } + if is_evolution: + turn["kind"] = "evolution" + if merged_extras: + turn["extras"] = merged_extras + turns.append(turn) + + return turns + + +class ConversationStore: + """ + SQLite-backed store for per-session conversation history. + + Usage: + store = ConversationStore(db_path) + store.append_messages("user_123", new_messages, channel_type="feishu") + msgs = store.load_messages("user_123", max_turns=30) + """ + + def __init__(self, db_path: Path): + self._db_path = db_path + self._lock = threading.RLock() # Use RLock to allow reentrant locking + self._init_db() + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def load_messages( + self, + session_id: str, + max_turns: int = 30, + ) -> List[Dict[str, Any]]: + """ + Load the most recent messages for a session, for injection into the LLM. + + ALL message types (user text, assistant tool_use, tool_result) are returned + in their original JSON form so the LLM can reconstruct the full context. + + max_turns is a *visible-turn* count: we count only user messages whose + content is actual user text (not tool_result blocks). This prevents + tool-heavy sessions from exhausting the turn budget prematurely. + + Args: + session_id: Unique session identifier. + max_turns: Maximum number of visible user-assistant turns to keep. + + Returns: + Chronologically ordered list of message dicts (role, content). + """ + with self._lock: + conn = self._connect() + try: + # Respect context_start_seq: only load messages at or after the boundary + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + + rows = conn.execute( + """ + SELECT seq, role, content + FROM messages + WHERE session_id = ? AND seq >= ? + ORDER BY seq DESC + """, + (session_id, ctx_start), + ).fetchall() + finally: + conn.close() + + if not rows: + return [] + + visible_turn_seqs: List[int] = [] + for seq, role, raw_content in rows: + if role != "user": + continue + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + if _is_visible_user_message(content): + visible_turn_seqs.append(seq) + + if len(visible_turn_seqs) <= max_turns: + cutoff_seq = None + else: + cutoff_seq = visible_turn_seqs[max_turns - 1] + + result = [] + for seq, role, raw_content in reversed(rows): + if cutoff_seq is not None and seq < cutoff_seq: + continue + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + # Strip thinking blocks — they are stored for UI display only + if role == "assistant" and isinstance(content, list): + content = [b for b in content if b.get("type") != "thinking"] + result.append({"role": role, "content": content}) + return result + + def append_messages( + self, + session_id: str, + messages: List[Dict[str, Any]], + channel_type: str = "", + ) -> None: + """ + Append new messages to a session's history. + + Seq numbers continue from the session's current maximum, so + concurrent callers on distinct sessions never collide. + + Args: + session_id: Unique session identifier. + messages: List of message dicts to append. + channel_type: Source channel (e.g. "feishu", "web", "wechat"). + Only written on session creation; ignored on update. + """ + if not messages: + return + + now = int(time.time()) + with self._lock: + conn = self._connect() + try: + with conn: + # INSERT OR IGNORE creates the row on first visit; + # the UPDATE always refreshes last_active. + # Avoids ON CONFLICT...DO UPDATE (requires SQLite >= 3.24). + conn.execute( + """ + INSERT OR IGNORE INTO sessions + (session_id, channel_type, created_at, last_active, msg_count) + VALUES (?, ?, ?, ?, 0) + """, + (session_id, channel_type, now, now), + ) + conn.execute( + "UPDATE sessions SET last_active = ? WHERE session_id = ?", + (now, session_id), + ) + + # Determine starting seq for the new batch. + row = conn.execute( + "SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + next_seq = row[0] + 1 + + for msg in messages: + role = msg.get("role", "") + content = json.dumps( + msg.get("content", ""), ensure_ascii=False + ) + extras_obj = msg.get("extras") or {} + extras = json.dumps(extras_obj, ensure_ascii=False) if extras_obj else "" + conn.execute( + """ + INSERT OR IGNORE INTO messages + (session_id, seq, role, content, created_at, extras) + VALUES (?, ?, ?, ?, ?, ?) + """, + (session_id, next_seq, role, content, now, extras), + ) + next_seq += 1 + + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + + # Auto-generate title from the first visible user message + cur_title = conn.execute( + "SELECT title FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if cur_title and not cur_title[0]: + for msg in messages: + if msg.get("role") == "user": + content = msg.get("content", "") + text = _extract_display_text(content) + if text: + title = text[:50].split("\n")[0] + conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + break + finally: + conn.close() + + def clear_context(self, session_id: str) -> int: + """ + Set the context boundary to after the current last message. + Messages before this boundary are still stored but excluded from LLM context. + + Returns the new context_start_seq value. + """ + with self._lock: + conn = self._connect() + try: + with conn: + row = conn.execute( + "SELECT COALESCE(MAX(seq), -1) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + new_start = row[0] + 1 + conn.execute( + "UPDATE sessions SET context_start_seq = ? WHERE session_id = ?", + (new_start, session_id), + ) + return new_start + finally: + conn.close() + + def get_context_start_seq(self, session_id: str) -> int: + """Return the context_start_seq for a session (0 if not set).""" + with self._lock: + conn = self._connect() + try: + row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + return row[0] if row else 0 + finally: + conn.close() + + def get_latest_pair_seqs(self, session_id: str) -> Dict[str, Optional[int]]: + """Return the seq numbers of the latest visible user message and the + latest assistant message in a session. + + A "visible" user message is one whose content is real user text + (not just a tool_result block), so tool-execution turns do not + shadow the actual user query. + + Returns: + Dict with keys ``user_seq`` and ``bot_seq``; either may be None + when no matching message exists. + """ + result: Dict[str, Optional[int]] = {"user_seq": None, "bot_seq": None} + with self._lock: + conn = self._connect() + try: + # Latest assistant message (cheap: single row by seq DESC). + row = conn.execute( + "SELECT seq FROM messages " + "WHERE session_id = ? AND role = 'assistant' " + "ORDER BY seq DESC LIMIT 1", + (session_id,), + ).fetchone() + if row: + result["bot_seq"] = int(row[0]) + + # Latest visible user message: scan recent user rows and + # skip pure tool_result entries. + rows = conn.execute( + "SELECT seq, content FROM messages " + "WHERE session_id = ? AND role = 'user' " + "ORDER BY seq DESC LIMIT 20", + (session_id,), + ).fetchall() + for seq, content_raw in rows: + try: + content = json.loads(content_raw) + except Exception: + result["user_seq"] = int(seq) + break + if isinstance(content, list): + has_text = any( + isinstance(b, dict) and b.get("type") == "text" + for b in content + ) + has_tool_result = any( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ) + if has_text and not has_tool_result: + result["user_seq"] = int(seq) + break + else: + result["user_seq"] = int(seq) + break + finally: + conn.close() + return result + + def clear_session(self, session_id: str) -> None: + """Delete all messages and the session record for a given session_id.""" + with self._lock: + conn = self._connect() + try: + with conn: + conn.execute( + "DELETE FROM messages WHERE session_id = ?", (session_id,) + ) + conn.execute( + "DELETE FROM sessions WHERE session_id = ?", (session_id,) + ) + finally: + conn.close() + + def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int: + """Delete a user message and/or its corresponding assistant reply. + + The assistant reply is identified as all messages between user_seq + and the next visible user message (or end of session). + + Args: + session_id: Session identifier. + user_seq: The seq number of the user message. + delete_user: If True (default), delete the user message too. + If False, only delete assistant reply (for regenerate scenarios). + cascade: If True, also delete all subsequent turns after this one. + Used by edit-message which removes this turn and everything after. + + Returns: + Number of message rows deleted. + """ + with self._lock: + conn = self._connect() + try: + with conn: + # Verify this is a user message + row = conn.execute( + "SELECT role FROM messages WHERE session_id = ? AND seq = ?", + (session_id, user_seq), + ).fetchone() + if not row or row[0] != "user": + return 0 + + if cascade: + # Delete from this message to end of session + start_seq = user_seq if delete_user else user_seq + 1 + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + else: + # Find the next visible user message seq (exclude tool_result) + # Use batched query to avoid loading too many rows at once + next_user_seq = None + batch_size = 100 + offset = 0 + while True: + batch = conn.execute( + """ + SELECT seq, content FROM messages + WHERE session_id = ? AND seq > ? AND role = 'user' + ORDER BY seq ASC + LIMIT ? OFFSET ? + """, + (session_id, user_seq, batch_size, offset), + ).fetchall() + if not batch: + break + for seq, content in batch: + try: + content_obj = json.loads(content) + except Exception: + content_obj = content + if _is_visible_user_message(content_obj): + next_user_seq = seq + break + if next_user_seq is not None: + break + offset += batch_size + + # Determine the end boundary for deletion + if next_user_seq is not None: + end_seq = next_user_seq + else: + end_seq_row = conn.execute( + "SELECT MAX(seq) FROM messages WHERE session_id = ?", + (session_id,), + ).fetchone() + end_seq = (end_seq_row[0] or user_seq) + 1 + + # Determine the start boundary for deletion + start_seq = user_seq if delete_user else user_seq + 1 + + # Delete messages from start_seq to end_seq (exclusive) + cur = conn.execute( + "DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?", + (session_id, start_seq, end_seq), + ) + deleted = cur.rowcount + + # Update session msg_count + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + + return deleted + finally: + conn.close() + + def prune_scheduled_messages( + self, + session_id: str, + keep_last_n: int, + markers: Optional[List[str]] = None, + ) -> int: + """ + Keep at most ``keep_last_n`` scheduler-injected user/assistant pairs in + the session, deleting the older ones. + + A scheduler-injected pair is identified by a user message whose first + text block starts with one of ``markers``; the immediately following + assistant message (next seq) is treated as its paired output. + + Only scheduler-tagged messages are touched; regular user turns are + never deleted. Safe to call repeatedly; no-op if nothing to prune. + + Args: + session_id: Session to prune. + keep_last_n: Maximum scheduler pairs to retain (must be >= 0). + markers: Text prefixes that identify scheduler user messages. + Defaults to ``["[SCHEDULED]", "Scheduled task"]`` so that + pairs written by older versions are also recognised. + + Returns: + Number of message rows deleted. + """ + if keep_last_n < 0: + keep_last_n = 0 + if markers is None: + markers = ["[SCHEDULED]", "Scheduled task"] + + def _matches_marker(raw_content: str) -> bool: + try: + parsed = json.loads(raw_content) + except Exception: + parsed = raw_content + text = _extract_display_text(parsed) if not isinstance(parsed, str) else parsed + if not text: + return False + return any(text.startswith(m) for m in markers) + + with self._lock: + conn = self._connect() + try: + rows = conn.execute( + """ + SELECT seq, role, content + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + + # Find scheduler pairs: each is (user_seq, assistant_seq?) + pairs: List[tuple] = [] # list of (user_seq, assistant_seq_or_None) + for idx, (seq, role, raw_content) in enumerate(rows): + if role != "user" or not _matches_marker(raw_content): + continue + assistant_seq = None + # Pair with the very next message if it's an assistant turn. + if idx + 1 < len(rows): + next_seq, next_role, _ = rows[idx + 1] + if next_role == "assistant": + assistant_seq = next_seq + pairs.append((seq, assistant_seq)) + + if len(pairs) <= keep_last_n: + return 0 + + to_delete_pairs = pairs[: len(pairs) - keep_last_n] + seqs_to_delete: List[int] = [] + for user_seq, assistant_seq in to_delete_pairs: + seqs_to_delete.append(user_seq) + if assistant_seq is not None: + seqs_to_delete.append(assistant_seq) + + if not seqs_to_delete: + return 0 + + placeholders = ",".join("?" * len(seqs_to_delete)) + with conn: + conn.execute( + f"DELETE FROM messages WHERE session_id = ? AND seq IN ({placeholders})", + (session_id, *seqs_to_delete), + ) + conn.execute( + """ + UPDATE sessions + SET msg_count = ( + SELECT COUNT(*) FROM messages WHERE session_id = ? + ) + WHERE session_id = ? + """, + (session_id, session_id), + ) + return len(seqs_to_delete) + finally: + conn.close() + + def cleanup_old_sessions(self, max_age_days: Optional[int] = None) -> int: + """ + Delete sessions that have not been active within max_age_days. + Web channel sessions are excluded — they are meant to be permanent. + + Args: + max_age_days: Override the default retention period. + + Returns: + Number of sessions deleted. + """ + try: + from config import conf + max_age = max_age_days or conf().get( + "conversation_max_age_days", DEFAULT_MAX_AGE_DAYS + ) + except Exception: + max_age = max_age_days or DEFAULT_MAX_AGE_DAYS + + cutoff = int(time.time()) - max_age * 86400 + deleted = 0 + + with self._lock: + conn = self._connect() + try: + with conn: + stale = conn.execute( + "SELECT session_id FROM sessions " + "WHERE last_active < ? AND channel_type != 'web'", + (cutoff,), + ).fetchall() + for (sid,) in stale: + conn.execute( + "DELETE FROM messages WHERE session_id = ?", (sid,) + ) + conn.execute( + "DELETE FROM sessions WHERE session_id = ?", (sid,) + ) + deleted += 1 + finally: + conn.close() + + if deleted: + logger.info(f"[ConversationStore] Pruned {deleted} expired sessions") + return deleted + + def attach_extras_to_last_assistant( + self, + session_id: str, + extras: Dict[str, Any], + ) -> Optional[int]: + """ + Merge ``extras`` into the latest assistant message of a session. + + Used by post-processing (e.g. TTS) that needs to annotate an already + persisted bot reply with attachments such as audio URLs. + + Returns the message seq that was updated, or ``None`` if no assistant + message exists or the update could not be applied. + """ + if not extras: + return None + with self._lock: + conn = self._connect() + try: + row = conn.execute( + """ + SELECT seq, extras FROM messages + WHERE session_id = ? AND role = 'assistant' + ORDER BY seq DESC LIMIT 1 + """, + (session_id,), + ).fetchone() + if not row: + return None + seq, raw = row + try: + cur = json.loads(raw) if raw else {} + if not isinstance(cur, dict): + cur = {} + except Exception: + cur = {} + cur.update(extras) + conn.execute( + "UPDATE messages SET extras = ? WHERE session_id = ? AND seq = ?", + (json.dumps(cur, ensure_ascii=False), session_id, seq), + ) + conn.commit() + return seq + except Exception as e: + logger.warning(f"[ConversationStore] attach_extras failed: {e}") + return None + finally: + conn.close() + + def load_history_page( + self, + session_id: str, + page: int = 1, + page_size: int = 20, + ) -> Dict[str, Any]: + """ + Load a page of conversation history for UI display, grouped into turns. + + Each "turn" maps to one of: + - A user message (role="user", content=str) + - An assistant message (role="assistant", content=str, + tool_calls=[{name, arguments, result}] when tools were used) + + Internal tool_result user messages are merged into the preceding + assistant entry's tool_calls list and never appear as standalone items. + + Pages are numbered from 1 (most recent). Messages within a page are + returned in chronological order. + + Returns: + { + "messages": [ + { + "role": "user" | "assistant", + "content": str, + "tool_calls": [...], # assistant only, may be [] + "created_at": int, + }, + ... + ], + "total": , + "page": , + "page_size": , + "has_more": bool, + } + """ + page = max(1, page) + with self._lock: + conn = self._connect() + try: + ctx_row = conn.execute( + "SELECT context_start_seq FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + ctx_start = ctx_row[0] if ctx_row else 0 + + # extras column is added by migration; tolerate older DBs that + # might miss it by falling back to a NULL literal. + try: + rows = conn.execute( + """ + SELECT seq, role, content, created_at, extras + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + except sqlite3.OperationalError: + rows = [ + (seq, role, content, created_at, "") + for (seq, role, content, created_at) in conn.execute( + """ + SELECT seq, role, content, created_at + FROM messages + WHERE session_id = ? + ORDER BY seq ASC + """, + (session_id,), + ).fetchall() + ] + finally: + conn.close() + + # Honour the current enable_thinking switch when building display turns + # so that toggling it off hides previously-saved thinking blocks too. + try: + from config import conf + include_thinking = bool(conf().get("enable_thinking", False)) + except Exception: + include_thinking = False + + # Strip seq for display grouping, but record max seq per visible user group + plain_rows = [ + (role, content, created_at, extras_raw) + for _seq, role, content, created_at, extras_raw in rows + ] + visible = _group_into_display_turns(plain_rows, include_thinking=include_thinking) + + # Build a mapping: find the seq of each visible user message to annotate context boundary. + # Walk through rows to find visible user message seqs in order. + visible_user_seqs: List[int] = [] + for seq, role, raw_content, _ts, _extras in rows: + if role != "user": + continue + try: + content = json.loads(raw_content) + except Exception: + content = raw_content + if _is_visible_user_message(content): + visible_user_seqs.append(seq) + + # Each pair of display turns (user+assistant) corresponds to a visible user seq. + # Mark which turns are before the context boundary. + user_turn_idx = 0 + for turn in visible: + if turn["role"] == "user" and user_turn_idx < len(visible_user_seqs): + turn["_seq"] = visible_user_seqs[user_turn_idx] + user_turn_idx += 1 + + total = len(visible) + offset = (page - 1) * page_size + page_items = list(reversed(visible))[offset: offset + page_size] + page_items = list(reversed(page_items)) + + return { + "messages": page_items, + "context_start_seq": ctx_start, + "total": total, + "page": page, + "page_size": page_size, + "has_more": offset + page_size < total, + } + + def list_sessions( + self, + channel_type: Optional[str] = None, + page: int = 1, + page_size: int = 50, + ) -> Dict[str, Any]: + """ + List sessions ordered by last_active DESC, with optional channel_type filter. + + Returns: + { + "sessions": [{session_id, title, created_at, last_active, msg_count}, ...], + "total": int, + "page": int, + "page_size": int, + "has_more": bool, + } + """ + page = max(1, page) + with self._lock: + conn = self._connect() + try: + if channel_type: + total = conn.execute( + "SELECT COUNT(*) FROM sessions WHERE channel_type = ?", + (channel_type,), + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + WHERE channel_type = ? + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (channel_type, page_size, (page - 1) * page_size), + ).fetchall() + else: + total = conn.execute( + "SELECT COUNT(*) FROM sessions", + ).fetchone()[0] + rows = conn.execute( + """ + SELECT session_id, title, created_at, last_active, msg_count + FROM sessions + ORDER BY last_active DESC + LIMIT ? OFFSET ? + """, + (page_size, (page - 1) * page_size), + ).fetchall() + finally: + conn.close() + + sessions = [ + { + "session_id": r[0], + "title": r[1], + "created_at": r[2], + "last_active": r[3], + "msg_count": r[4], + } + for r in rows + ] + return { + "sessions": sessions, + "total": total, + "page": page, + "page_size": page_size, + "has_more": (page - 1) * page_size + page_size < total, + } + + def rename_session(self, session_id: str, title: str) -> bool: + """Update the title of a session. Returns True if the session existed.""" + with self._lock: + conn = self._connect() + try: + with conn: + cur = conn.execute( + "UPDATE sessions SET title = ? WHERE session_id = ?", + (title, session_id), + ) + return cur.rowcount > 0 + finally: + conn.close() + + def get_stats(self) -> Dict[str, Any]: + """Return basic stats keyed by channel_type, for monitoring.""" + with self._lock: + conn = self._connect() + try: + total_sessions = conn.execute( + "SELECT COUNT(*) FROM sessions" + ).fetchone()[0] + total_messages = conn.execute( + "SELECT COUNT(*) FROM messages" + ).fetchone()[0] + by_channel = conn.execute( + """ + SELECT channel_type, COUNT(*) as cnt + FROM sessions + GROUP BY channel_type + ORDER BY cnt DESC + """ + ).fetchall() + return { + "total_sessions": total_sessions, + "total_messages": total_messages, + "by_channel": {row[0] or "unknown": row[1] for row in by_channel}, + } + finally: + conn.close() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _init_db(self) -> None: + self._db_path.parent.mkdir(parents=True, exist_ok=True) + conn = self._connect() + try: + conn.executescript(_DDL) + conn.commit() + self._migrate(conn) + finally: + conn.close() + + def _migrate(self, conn: sqlite3.Connection) -> None: + """Apply incremental schema migrations on existing databases.""" + cols = { + row[1] + for row in conn.execute("PRAGMA table_info(sessions)").fetchall() + } + if "channel_type" not in cols: + try: + conn.execute(_MIGRATION_ADD_CHANNEL_TYPE) + conn.commit() + logger.info("[ConversationStore] Migrated: added channel_type column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration failed: {e}") + if "title" not in cols: + try: + conn.execute(_MIGRATION_ADD_TITLE) + conn.commit() + logger.info("[ConversationStore] Migrated: added title column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (title) failed: {e}") + if "context_start_seq" not in cols: + try: + conn.execute(_MIGRATION_ADD_CONTEXT_START_SEQ) + conn.commit() + logger.info("[ConversationStore] Migrated: added context_start_seq column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (context_start_seq) failed: {e}") + + msg_cols = { + row[1] + for row in conn.execute("PRAGMA table_info(messages)").fetchall() + } + if "extras" not in msg_cols: + try: + conn.execute(_MIGRATION_ADD_MSG_EXTRAS) + conn.commit() + logger.info("[ConversationStore] Migrated: added messages.extras column") + except Exception as e: + logger.warning(f"[ConversationStore] Migration (extras) failed: {e}") + + def _connect(self) -> sqlite3.Connection: + conn = sqlite3.connect(str(self._db_path), timeout=10) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA synchronous=NORMAL") + return conn + + +# --------------------------------------------------------------------------- +# Singleton +# --------------------------------------------------------------------------- + +_store_instance: Optional[ConversationStore] = None +_store_lock = threading.Lock() + + +def get_conversation_store() -> ConversationStore: + """ + Return the process-wide ConversationStore singleton. + + Reuses the long-term memory database so the project stays with a single + SQLite file: ~/cow/memory/long-term/index.db + The conversation tables (sessions / messages) are separate from the + memory tables (memory_chunks / file_metadata) — no conflicts. + """ + global _store_instance + if _store_instance is not None: + return _store_instance + + with _store_lock: + if _store_instance is not None: + return _store_instance + + try: + from agent.memory.config import get_default_memory_config + db_path = get_default_memory_config().get_db_path() + except Exception: + from common.utils import expand_path + db_path = Path(expand_path("~/cow")) / "memory" / "long-term" / "index.db" + + _store_instance = ConversationStore(db_path) + logger.debug(f"[ConversationStore] Using shared DB at: {db_path}") + return _store_instance + diff --git a/agent/memory/embedding/__init__.py b/agent/memory/embedding/__init__.py new file mode 100644 index 0000000..276b017 --- /dev/null +++ b/agent/memory/embedding/__init__.py @@ -0,0 +1,43 @@ +""" +Embedding subsystem for memory. + +Public API: + create_embedding_provider, EmbeddingProvider, OpenAIEmbeddingProvider, + EMBEDDING_VENDORS, EmbeddingCache + RebuildResult, clear_index, rebuild_in_process + detect_index_dim, cleanup_legacy_state_file +""" + +from agent.memory.embedding.provider import ( + EMBEDDING_VENDORS, + DoubaoEmbeddingProvider, + EmbeddingCache, + EmbeddingProvider, + OpenAIEmbeddingProvider, + create_embedding_provider, +) +from agent.memory.embedding.factory import create_default_embedding_provider +from agent.memory.embedding.rebuild import ( + RebuildResult, + clear_index, + rebuild_in_process, +) +from agent.memory.embedding.state import ( + cleanup_legacy_state_file, + detect_index_dim, +) + +__all__ = [ + "EMBEDDING_VENDORS", + "DoubaoEmbeddingProvider", + "EmbeddingCache", + "EmbeddingProvider", + "OpenAIEmbeddingProvider", + "create_embedding_provider", + "create_default_embedding_provider", + "RebuildResult", + "clear_index", + "rebuild_in_process", + "cleanup_legacy_state_file", + "detect_index_dim", +] diff --git a/agent/memory/embedding/factory.py b/agent/memory/embedding/factory.py new file mode 100644 index 0000000..0a50f18 --- /dev/null +++ b/agent/memory/embedding/factory.py @@ -0,0 +1,209 @@ +""" +Shared embedding provider factory. + +Resolves the embedding provider purely from config.json, so every caller +(agent initialization, knowledge base sync, index rebuild, ...) selects the +same provider instead of silently degrading to keyword-only search. + +Two paths: + A. Default (no `embedding_provider` in config.json): + Auto-init OpenAI -> LinkAI fallback. + B. Explicit (`embedding_provider` is set): + Initialize the requested vendor with unified dim (default per vendor). +""" + +import os +from typing import Optional + +from common.log import logger + +# Track whether the embedding model log has been printed in this process, +# so we avoid spamming it once per session/caller. +_embedding_logged: bool = False + + +def create_default_embedding_provider(): + """Build the embedding provider from config, or None for keyword-only mode.""" + from config import conf + + explicit_provider = (conf().get("embedding_provider") or "").strip().lower() + if not explicit_provider: + return _init_legacy_provider() + return _init_explicit_provider(explicit_provider) + + +def _init_legacy_provider(): + """Legacy auto-init path: OpenAI -> LinkAI.""" + from agent.memory.embedding.provider import create_embedding_provider + from config import conf + + embedding_provider = None + embedding_model = None + + openai_api_key = conf().get("open_ai_api_key", "") + openai_api_base = conf().get("open_ai_api_base", "") + if openai_api_key and openai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]: + try: + model = "text-embedding-3-small" + embedding_provider = create_embedding_provider( + provider="openai", + model=model, + api_key=openai_api_key, + api_base=openai_api_base or "https://api.openai.com/v1", + ) + embedding_model = f"openai/{model}" + except Exception as e: + logger.warning(f"[EmbeddingFactory] OpenAI embedding failed: {e}") + + if embedding_provider is None: + linkai_api_key = conf().get("linkai_api_key", "") or os.environ.get("LINKAI_API_KEY", "") + linkai_api_base = conf().get("linkai_api_base", "https://api.link-ai.tech") + if linkai_api_key and linkai_api_key not in ["", "YOUR API KEY", "YOUR_API_KEY"]: + try: + model = "text-embedding-3-small" + embedding_provider = create_embedding_provider( + provider="linkai", + model=model, + api_key=linkai_api_key, + api_base=f"{linkai_api_base}/v1", + ) + embedding_model = f"linkai/{model}" + except Exception as e: + logger.warning(f"[EmbeddingFactory] LinkAI embedding failed: {e}") + + if embedding_provider is not None and embedding_model: + _log_provider_once(f"{embedding_model} (dim={embedding_provider.dimensions})") + + return embedding_provider + + +def _init_explicit_provider(provider_key: str): + """Explicit-provider path: build the configured vendor.""" + from agent.memory.embedding.provider import EMBEDDING_VENDORS, create_embedding_provider + from config import conf + + # Custom providers ("custom:") resolve credentials from custom_providers. + resolved_provider_key = provider_key + if provider_key.startswith("custom:"): + resolved_provider_key = "custom" + + meta = EMBEDDING_VENDORS.get(resolved_provider_key) + if meta is None: + logger.error( + f"[EmbeddingFactory] Unknown embedding_provider '{provider_key}'. " + f"Supported: {sorted(EMBEDDING_VENDORS.keys())}. " + f"Memory will run in keyword-only mode." + ) + return None + + api_key = _resolve_api_key(provider_key) + api_base = _resolve_api_base(provider_key, meta["default_base_url"]) + + if not api_key: + logger.error( + f"[EmbeddingFactory] embedding_provider='{provider_key}' is set but its " + f"API key is missing. Memory will run in keyword-only mode." + ) + return None + + model = (conf().get("embedding_model") or "").strip() + # Custom providers without a model fall back to the provider's default. + if not model and resolved_provider_key == "custom": + from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id + _, custom_id = parse_custom_bot_type(provider_key) + if custom_id: + entry = _find_provider_by_id(get_custom_providers(), custom_id) + if entry and entry.get("model"): + model = entry["model"] + if not model and resolved_provider_key != "custom": + model = meta["default_model"] + + try: + cfg_dim = int(conf().get("embedding_dimensions") or 0) + except (TypeError, ValueError): + cfg_dim = 0 + dim = cfg_dim if cfg_dim > 0 else meta["default_dimensions"] + + try: + provider = create_embedding_provider( + provider=resolved_provider_key, + model=model, + api_key=api_key, + api_base=api_base, + dimensions=dim, + ) + except Exception as e: + logger.error( + f"[EmbeddingFactory] Failed to init embedding provider " + f"'{provider_key}/{model}': {e}" + ) + return None + + _log_provider_once(f"{provider_key}/{model} (dim={provider.dimensions})") + return provider + + +def _resolve_api_key(provider_key: str) -> str: + """Pick the API key for an explicit embedding provider from config.""" + from config import conf + + if provider_key.startswith("custom:"): + from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id + _, custom_id = parse_custom_bot_type(provider_key) + if custom_id: + entry = _find_provider_by_id(get_custom_providers(), custom_id) + if entry: + return entry.get("api_key", "") + return "" + + key_map = { + "openai": "open_ai_api_key", + "linkai": "linkai_api_key", + "dashscope": "dashscope_api_key", + "doubao": "ark_api_key", + "zhipu": "zhipu_ai_api_key", + } + field = key_map.get(provider_key) + if not field: + return "" + value = conf().get(field, "") or "" + if value in ["", "YOUR API KEY", "YOUR_API_KEY"]: + return "" + return value + + +def _resolve_api_base(provider_key: str, default_base: str) -> str: + """Pick the API base for an explicit embedding provider from config.""" + from config import conf + + if provider_key.startswith("custom:"): + from models.custom_provider import parse_custom_bot_type, get_custom_providers, _find_provider_by_id + _, custom_id = parse_custom_bot_type(provider_key) + if custom_id: + entry = _find_provider_by_id(get_custom_providers(), custom_id) + if entry and entry.get("api_base"): + return entry["api_base"] + return default_base + + base_map = { + "openai": "open_ai_api_base", + "linkai": "linkai_api_base", + "doubao": "ark_base_url", + "zhipu": "zhipu_ai_api_base", + } + field = base_map.get(provider_key) + if not field: + return default_base + value = (conf().get(field) or "").strip() + if not value: + return default_base + if provider_key == "linkai" and not value.rstrip("/").endswith("/v1"): + return f"{value.rstrip('/')}/v1" + return value + + +def _log_provider_once(detail: str): + global _embedding_logged + if not _embedding_logged: + logger.info(f"[EmbeddingFactory] Embedding model in use: {detail}") + _embedding_logged = True diff --git a/agent/memory/embedding/provider.py b/agent/memory/embedding/provider.py new file mode 100644 index 0000000..6fcaf13 --- /dev/null +++ b/agent/memory/embedding/provider.py @@ -0,0 +1,515 @@ +""" +Embedding providers for memory + +Supports multiple OpenAI-compatible embedding vendors: + - openai (text-embedding-3-small / large) + - linkai (OpenAI-compatible passthrough) + - dashscope (Aliyun Tongyi text-embedding-v4) + - doubao (ByteDance Doubao Seed1.5 / large-text on Volcengine Ark) + - zhipu (ZhipuAI embedding-3) + - custom (any OpenAI-compatible endpoint) + +Vendor keys here intentionally match the project's bot_type constants in +common.const (OPENAI, LINKAI, QWEN_DASHSCOPE, DOUBAO, ZHIPU_AI). + +Custom providers (bot_type "custom" or "custom:") reuse the same +OpenAI-compatible REST client with user-supplied api_key / api_base. + +All providers share a single OpenAI-compatible REST client. Vendor-specific +behaviors (truncation, query instruction prefix) are configured via metadata. +""" + +import hashlib +import math +from abc import ABC, abstractmethod +from typing import List, Optional + +# HTTP read timeout for a single embeddings request (seconds). A batch of +# 64+ chunks can take 30-50s end-to-end from China-side networks, so 30s is +# routinely too tight; 90s gives meaningful headroom without letting bad +# endpoints hang forever. +EMBEDDING_HTTP_TIMEOUT = 90 + + +class EmbeddingProvider(ABC): + """Base class for embedding providers""" + + @abstractmethod + def embed(self, text: str) -> List[float]: + """Generate embedding for a single text (treated as a query by default)""" + pass + + @abstractmethod + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple texts (treated as documents)""" + pass + + def embed_query(self, text: str) -> List[float]: + """Generate embedding for a query string (may apply vendor instruction prefix)""" + return self.embed(text) + + @property + @abstractmethod + def dimensions(self) -> int: + """Effective embedding dimensions""" + pass + + +# --------------------------------------------------------------------------- +# Vendor metadata table +# --------------------------------------------------------------------------- +# +# Each entry describes how to reach a vendor's embedding endpoint. Most +# vendors expose an OpenAI-compatible /embeddings API; the few that don't +# (currently: doubao) set `provider_class` to pick a dedicated adapter. +# Fields: +# provider_class : optional adapter key ("doubao"); defaults to OpenAI-compat +# default_base_url : default API base when not overridden by user +# default_model : default embedding model name +# default_dimensions : recommended unified dim when explicit path is enabled +# supports_dim_param : whether the API accepts a `dimensions` request param +# needs_client_truncate : whether to slice + L2-normalize on the client side +# needs_client_normalize : whether to L2-normalize on the client (always safe) +# query_instruction : optional prefix for asymmetric retrieval (Doubao Seed) +# max_batch_size : max texts per /embeddings request; embed_batch +# auto-paginates above this. Conservative defaults. +# +EMBEDDING_VENDORS = { + "openai": { + "default_base_url": "https://api.openai.com/v1", + "default_model": "text-embedding-3-small", + # Match the legacy default so users adding `embedding_provider: openai` + # to an existing index don't need to rebuild. Override via + # embedding_dimensions if you want 1024 / 1536 / 3072. + "default_dimensions": 1536, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + # OpenAI permits up to 2048 items per request, but a single call + # carrying hundreds of long chunks routinely exceeds the 30s read + # timeout from China-side networks. 64 keeps each call well under + # both the token-per-request budget and a reasonable wall clock. + "max_batch_size": 64, + }, + "linkai": { + "default_base_url": "https://api.link-ai.tech/v1", + "default_model": "text-embedding-3-small", + "default_dimensions": 1536, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 64, + }, + "dashscope": { + "default_base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "default_model": "text-embedding-v4", + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 10, # DashScope hard cap (text-embedding-v4) + }, + "doubao": { + # Doubao no longer offers an OpenAI-compatible /v1/embeddings endpoint. + # Current models are unified under /api/v3/embeddings/multimodal + # which uses a structured `input` payload — see DoubaoEmbeddingProvider. + "provider_class": "doubao", + "default_base_url": "https://ark.cn-beijing.volces.com/api/v3", + "default_model": "doubao-embedding-vision-251215", + # Native options: 1024 or 2048. We default to 1024 to align with the + # other Chinese vendors (dashscope/zhipu) and keep storage footprint + # consistent across providers; users can still override via + # `embedding_dimensions: 2048` in config. + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + # Multimodal endpoint produces ONE embedding per call (input list is + # a single document's parts, not a batch). embed_batch loops. + "max_batch_size": 1, + }, + "zhipu": { + "default_base_url": "https://open.bigmodel.cn/api/paas/v4", + "default_model": "embedding-3", + "default_dimensions": 1024, + "supports_dim_param": True, + "needs_client_truncate": False, + "needs_client_normalize": False, + "query_instruction": "", + "max_batch_size": 64, + }, + # Custom provider — any OpenAI-compatible /embeddings endpoint. The + # user must supply api_key + api_base + model via the web console + # (stored in custom_providers list or legacy custom_api_key / custom_api_base). + # Dimensions defaults to 1024 but can be overridden via config's + # embedding_dimensions. No dim-param support assumption — safest + # default for unknown endpoints. + "custom": { + "default_base_url": "", + "default_model": "", + "default_dimensions": 1024, + "supports_dim_param": False, + "needs_client_truncate": False, + "needs_client_normalize": True, + "query_instruction": "", + "max_batch_size": 64, + }, +} + + +def _l2_normalize(vec: List[float]) -> List[float]: + """Normalize a vector to unit length (L2 norm). Returns input on zero vector.""" + norm = math.sqrt(sum(v * v for v in vec)) + if norm == 0: + return vec + return [v / norm for v in vec] + + +class OpenAIEmbeddingProvider(EmbeddingProvider): + """ + OpenAI-compatible embedding provider. + + Used for openai/linkai/dashscope/ark/zhipu by configuring the metadata + fields. The legacy two-arg constructor (model, api_key, api_base) keeps + working, so the original OpenAI/LinkAI fallback code path is unchanged. + """ + + def __init__( + self, + model: str = "text-embedding-3-small", + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, + supports_dim_param: bool = True, + needs_client_truncate: bool = False, + needs_client_normalize: bool = False, + query_instruction: str = "", + max_batch_size: int = 256, + ): + """ + Args: + model: Model name (e.g. text-embedding-3-small, text-embedding-v4, embedding-3) + api_key: API key (required) + api_base: API base URL (defaults to OpenAI) + extra_headers: Optional extra HTTP headers + dimensions: Target output dimension. Required when supports_dim_param + is False and needs_client_truncate is True (used to slice). + supports_dim_param: Whether the vendor accepts a `dimensions` body param + needs_client_truncate: Slice the returned vector to `dimensions` + needs_client_normalize: L2-normalize on the client after slicing + query_instruction: Optional prefix prepended to query texts only + max_batch_size: Max items per /embeddings request; embed_batch + auto-paginates above this. + """ + self.model = model + self.api_key = api_key + self.api_base = api_base or "https://api.openai.com/v1" + self.extra_headers = extra_headers or {} + self.supports_dim_param = supports_dim_param + self.needs_client_truncate = needs_client_truncate + self.needs_client_normalize = needs_client_normalize + self.query_instruction = query_instruction or "" + self.max_batch_size = max(1, int(max_batch_size or 1)) + + if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]: + raise ValueError("Embedding API key is not configured") + + if dimensions is not None and dimensions > 0: + self._dimensions = dimensions + else: + # Legacy heuristic for OpenAI text-embedding-3-* family + self._dimensions = 1536 if "small" in model else 3072 + + def _call_api(self, input_data): + """Call OpenAI-compatible /embeddings endpoint""" + import requests + + url = f"{self.api_base}/embeddings" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + **self.extra_headers, + } + data = { + "input": input_data, + "model": self.model, + } + if self.supports_dim_param and self._dimensions: + data["dimensions"] = self._dimensions + + try: + response = requests.post(url, headers=headers, json=data, timeout=EMBEDDING_HTTP_TIMEOUT) + response.raise_for_status() + return response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError( + f"Failed to connect to embedding API at {url}. " + f"Please check network and api_base. Error: {str(e)}" + ) + except requests.exceptions.Timeout as e: + raise TimeoutError(f"Embedding API request timed out. Error: {str(e)}") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise ValueError("Invalid embedding API key") + elif e.response.status_code == 429: + raise ValueError("Embedding API rate limit exceeded") + else: + raise ValueError( + f"Embedding API request failed: " + f"{e.response.status_code} - {e.response.text}" + ) + + def _post_process(self, raw: List[float]) -> List[float]: + """Apply optional client-side truncation + normalization""" + vec = raw + if self.needs_client_truncate and self._dimensions and len(vec) > self._dimensions: + vec = vec[: self._dimensions] + if self.needs_client_normalize: + vec = _l2_normalize(vec) + return vec + + def embed(self, text: str) -> List[float]: + """Generate embedding (treated as document by default)""" + result = self._call_api(text) + return self._post_process(result["data"][0]["embedding"]) + + def embed_query(self, text: str) -> List[float]: + """Generate embedding for a query (applies vendor instruction prefix if any)""" + if self.query_instruction: + text = f"{self.query_instruction}{text}" + return self.embed(text) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + """Generate embeddings for multiple documents. + + Automatically paginates by self.max_batch_size so callers can pass any + number of texts. Order of returned vectors matches the input order. + """ + if not texts: + return [] + out: List[List[float]] = [] + step = self.max_batch_size + for i in range(0, len(texts), step): + chunk = texts[i:i + step] + result = self._call_api(chunk) + out.extend(self._post_process(item["embedding"]) for item in result["data"]) + return out + + @property + def dimensions(self) -> int: + return self._dimensions + + +class DoubaoEmbeddingProvider(EmbeddingProvider): + """ + Doubao (Volcengine Ark) multimodal embedding provider. + + Doubao deprecated their OpenAI-compatible /v1/embeddings endpoint and + unified everything under /api/v3/embeddings/multimodal, which uses a + structured `input: [{type, text|image_url|video_url}, ...]` payload. + + Notes: + * The endpoint produces ONE embedding per call (input list is multiple + modality parts of a single document, not a batch). embed_batch + therefore loops per-text — no native batch support. + * Native dimensions: 1024 or 2048 (default 1024 to align with other + Chinese vendors). No client-side truncation needed. + * Auth: Bearer ARK API key. + """ + + def __init__( + self, + model: str, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, + ): + self.model = model + self.api_key = api_key + self.api_base = api_base or "https://ark.cn-beijing.volces.com/api/v3" + self.extra_headers = extra_headers or {} + if not self.api_key or self.api_key in ["", "YOUR API KEY", "YOUR_API_KEY"]: + raise ValueError("Doubao embedding API key (ark_api_key) is not configured") + + if dimensions in (1024, 2048): + self._dimensions = dimensions + elif dimensions is None: + self._dimensions = 1024 + else: + raise ValueError( + f"Doubao embedding dimensions must be 1024 or 2048, got {dimensions}" + ) + + def _call_api(self, text: str) -> List[float]: + """One call → one embedding. multimodal endpoint takes a single + document represented as a list of typed parts; we send a single + text part.""" + import requests + + url = f"{self.api_base}/embeddings/multimodal" + headers = { + "Content-Type": "application/json", + "Authorization": f"Bearer {self.api_key}", + **self.extra_headers, + } + payload = { + "model": self.model, + "input": [{"type": "text", "text": text}], + "dimensions": self._dimensions, + "encoding_format": "float", + } + + try: + response = requests.post(url, headers=headers, json=payload, timeout=EMBEDDING_HTTP_TIMEOUT) + response.raise_for_status() + body = response.json() + except requests.exceptions.ConnectionError as e: + raise ConnectionError( + f"Failed to connect to Doubao embedding API at {url}. " + f"Please check network and api_base. Error: {str(e)}" + ) + except requests.exceptions.Timeout as e: + raise TimeoutError(f"Doubao embedding API request timed out. Error: {str(e)}") + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + raise ValueError("Invalid Doubao (ark) embedding API key") + elif e.response.status_code == 429: + raise ValueError("Doubao embedding API rate limit exceeded") + else: + raise ValueError( + f"Doubao embedding API request failed: " + f"{e.response.status_code} - {e.response.text}" + ) + + # Response shape per docs: {"data": {"embedding": [...]}} + data = body.get("data") + if isinstance(data, dict) and "embedding" in data: + return data["embedding"] + # Some providers wrap as a list of one — be defensive + if isinstance(data, list) and data and "embedding" in data[0]: + return data[0]["embedding"] + raise ValueError(f"Unexpected Doubao embedding response shape: {body}") + + def embed(self, text: str) -> List[float]: + return self._call_api(text) + + def embed_batch(self, texts: List[str]) -> List[List[float]]: + # Endpoint produces one embedding per call; loop. Order preserved. + return [self._call_api(t) for t in texts] + + @property + def dimensions(self) -> int: + return self._dimensions + + +class EmbeddingCache: + """In-memory cache for embeddings to avoid recomputation""" + + def __init__(self): + self.cache = {} + + def get(self, text: str, provider: str, model: str) -> Optional[List[float]]: + key = self._compute_key(text, provider, model) + return self.cache.get(key) + + def put(self, text: str, provider: str, model: str, embedding: List[float]): + key = self._compute_key(text, provider, model) + self.cache[key] = embedding + + @staticmethod + def _compute_key(text: str, provider: str, model: str) -> str: + content = f"{provider}:{model}:{text}" + return hashlib.md5(content.encode("utf-8")).hexdigest() + + def clear(self): + self.cache.clear() + + +def create_embedding_provider( + provider: str = "openai", + model: Optional[str] = None, + api_key: Optional[str] = None, + api_base: Optional[str] = None, + extra_headers: Optional[dict] = None, + dimensions: Optional[int] = None, +) -> EmbeddingProvider: + """ + Factory function to create an embedding provider. + + Backward compatible: when called with provider in {"openai", "linkai"} + and no `dimensions` arg, behaves exactly as before (1536-dim OpenAI). + + New providers ("dashscope", "doubao", "zhipu") require explicit configuration + and use the unified 1024-dim defaults from EMBEDDING_VENDORS. + + Args: + provider: Vendor key (one of EMBEDDING_VENDORS) + model: Model name (uses vendor default if None) + api_key: API key (required) + api_base: API base URL (uses vendor default if None) + extra_headers: Optional extra HTTP headers + dimensions: Target output dimension (uses vendor default if None) + + Returns: + EmbeddingProvider instance + """ + meta = EMBEDDING_VENDORS.get(provider) + if meta is None: + raise ValueError( + f"Unsupported embedding provider: {provider}. " + f"Supported: {sorted(EMBEDDING_VENDORS.keys())}" + ) + + # Doubao uses a non-OpenAI-compatible multimodal endpoint. + if meta.get("provider_class") == "doubao": + final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"] + return DoubaoEmbeddingProvider( + model=model or meta["default_model"], + api_key=api_key, + api_base=api_base or meta["default_base_url"], + extra_headers=extra_headers, + dimensions=final_dim, + ) + + # Legacy two-arg call for openai/linkai keeps 1536-dim default behavior + # so existing data isn't invalidated. + is_legacy_call = ( + provider in ("openai", "linkai") + and dimensions is None + ) + if is_legacy_call: + return OpenAIEmbeddingProvider( + model=model or "text-embedding-3-small", + api_key=api_key, + api_base=api_base, + extra_headers=extra_headers, + ) + + final_dim = dimensions if (dimensions and dimensions > 0) else meta["default_dimensions"] + resolved_model = model or meta["default_model"] + resolved_base = api_base or meta["default_base_url"] + # Custom providers require explicit api_base and model — they cannot + # fall back to OpenAI defaults like built-in vendors do. + if provider == "custom": + if not resolved_base: + raise ValueError("Custom embedding provider requires an api_base URL") + if not resolved_model: + raise ValueError("Custom embedding provider requires a model name") + return OpenAIEmbeddingProvider( + model=resolved_model, + api_key=api_key, + api_base=resolved_base, + extra_headers=extra_headers, + dimensions=final_dim, + supports_dim_param=meta["supports_dim_param"], + needs_client_truncate=meta["needs_client_truncate"], + needs_client_normalize=meta["needs_client_normalize"], + query_instruction=meta["query_instruction"], + max_batch_size=meta.get("max_batch_size", 256), + ) diff --git a/agent/memory/embedding/rebuild.py b/agent/memory/embedding/rebuild.py new file mode 100644 index 0000000..96d9878 --- /dev/null +++ b/agent/memory/embedding/rebuild.py @@ -0,0 +1,190 @@ +""" +Rebuild memory vector index. + +Recommended entry point (in-chat, while agent is running): + /memory rebuild-index + +Backward-compatible CLI entry (must run from project root): + python -m agent.memory.rebuild_index + +What it does: + 1. Probes the embedding endpoint with a tiny call to fail fast on + bad provider/model/key — before touching the index. + 2. Clears the SQLite chunks/files tables (workspace markdown stays intact). + 3. Runs a fresh sync, regenerating embeddings with the currently configured + provider/model/dimensions. + +This is the only safe way to switch embedding_provider after the existing +index has been populated by a different-dim model. +""" + +from __future__ import annotations +import asyncio +import sys +from dataclasses import dataclass +from typing import Optional + +from common.log import logger +from common.utils import expand_path + + +@dataclass +class RebuildResult: + """Outcome of a rebuild_in_process() call""" + ok: bool + removed: int = 0 + chunks: int = 0 + files: int = 0 + error: Optional[str] = None + + +def clear_index(db_path, storage=None) -> int: + """Wipe chunks/files, reset FTS5, and clean up any legacy state file. + + Args: + db_path: Path of the index DB (also used to locate the legacy state + file for migration cleanup, and — when *storage* is None — to + open a fresh connection). + storage: Optional pre-opened MemoryStorage. When provided we reuse it + so the live connection's triggers stay in sync — opening a second + connection would leave the original one's triggers pointing at a + DROP'd chunks_fts table. + + We reset (DROP+recreate) chunks_fts because its shadow tables can become + inconsistent across rebuild cycles, causing bm25() / ORDER BY rank to + raise "database disk image is malformed" even when raw MATCH still works. + + Returns number of chunks removed. + """ + from agent.memory.embedding.state import cleanup_legacy_state_file + from agent.memory.storage import MemoryStorage + + owns_storage = storage is None + if owns_storage: + storage = MemoryStorage(db_path) + try: + before = storage.conn.execute("SELECT COUNT(*) FROM chunks").fetchone()[0] + storage.conn.execute("DELETE FROM chunks") + storage.conn.execute("DELETE FROM files") + storage.conn.commit() + storage.reset_fts5() + finally: + if owns_storage: + storage.close() + + cleanup_legacy_state_file(db_path) + return int(before) + + +def rebuild_in_process(memory_manager) -> RebuildResult: + """ + Rebuild the index using an existing, fully-initialized MemoryManager. + + Used by the in-chat /memory rebuild-index command. The caller already has + config loaded, embedding_provider built, and (optionally) the agent + running, so we only need to: + 1. Clear chunks/files + state on the manager's storage. + 2. Re-sync (force=True). + + NOTE: caller must ensure memory_manager.embedding_provider is set, otherwise + sync() will silently skip embedding generation. + """ + if memory_manager is None: + return RebuildResult(ok=False, error="memory_manager is None") + if memory_manager.embedding_provider is None: + return RebuildResult(ok=False, error="embedding_provider is not initialized") + + # Probe the embedding endpoint BEFORE clearing the index. A bad + # provider/model/key would otherwise leave the user with an empty index + # that not even keyword search can serve. + try: + memory_manager.embedding_provider.embed_query("ping") + except Exception as e: + logger.error(f"[RebuildIndex] embedding probe failed, aborting rebuild: {e}") + return RebuildResult(ok=False, error=f"embedding endpoint not reachable: {e}") + + db_path = memory_manager.config.get_db_path() + try: + removed = clear_index(db_path, storage=memory_manager.storage) + except Exception as e: + logger.exception("[RebuildIndex] clear_index failed") + return RebuildResult(ok=False, error=f"clear failed: {e}") + + try: + asyncio.run(memory_manager.sync(force=True)) + except RuntimeError: + # Already inside a running event loop (rare in chat handler thread). + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(memory_manager.sync(force=True)) + finally: + loop.close() + except Exception as e: + logger.exception("[RebuildIndex] sync failed") + return RebuildResult(ok=False, removed=removed, error=f"re-embed failed: {e}") + + stats = memory_manager.storage.get_stats() + chunks = int(stats.get("chunks", 0)) + embedded = int(stats.get("embedded", 0)) + + # sync() degrades to "no embeddings" on batch failure so keyword search + # still works at startup — but in a /rebuild-index request the user + # explicitly asked for vectors. Surface that as a failure. + if chunks > 0 and embedded == 0: + return RebuildResult( + ok=False, + removed=removed, + chunks=chunks, + files=int(stats.get("files", 0)), + error=( + "embedding API failed during sync; index now has chunks but no " + "vectors. Check embedding provider/model/key and retry." + ), + ) + + return RebuildResult( + ok=True, + removed=removed, + chunks=chunks, + files=int(stats.get("files", 0)), + ) + + +def main() -> int: + """Standalone CLI entry. Must be run from project root (relative config path).""" + from config import conf, load_config + from agent.memory import MemoryConfig, MemoryManager + + load_config() + + workspace_root = expand_path(conf().get("agent_workspace", "~/cow")) + memory_config = MemoryConfig(workspace_root=workspace_root) + + logger.info(f"[RebuildIndex] Workspace: {workspace_root}") + logger.info(f"[RebuildIndex] Index db: {memory_config.get_db_path()}") + + from agent.memory.embedding import create_default_embedding_provider + + embedding_provider = create_default_embedding_provider() + if embedding_provider is None: + logger.error( + "[RebuildIndex] No embedding provider could be initialized. " + "Check your config.json. Aborting rebuild." + ) + return 1 + + manager = MemoryManager(memory_config, embedding_provider=embedding_provider) + result = rebuild_in_process(manager) + if not result.ok: + logger.error(f"[RebuildIndex] {result.error}") + return 1 + + logger.info( + f"[RebuildIndex] Done. removed={result.removed}, " + f"chunks={result.chunks}, files={result.files}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/agent/memory/embedding/state.py b/agent/memory/embedding/state.py new file mode 100644 index 0000000..5efffef --- /dev/null +++ b/agent/memory/embedding/state.py @@ -0,0 +1,51 @@ +""" +Embedding-related index utilities. + +We don't keep a sidecar state file — the SQLite index is the source of truth +and config.json is the source of intent. The two functions below are the +only things needing on-disk awareness: + + detect_index_dim : read the dim of stored vectors (display-only) + cleanup_legacy_state_file: remove old embedding_state.json from earlier + versions; safe no-op when absent. +""" + +from __future__ import annotations +import json +import os +from pathlib import Path +from typing import Optional, Union + +PathLike = Union[str, os.PathLike] + + +def detect_index_dim(storage) -> Optional[int]: + """Return the dim of the first stored embedding, or None if the index + has no embeddings. Used by /memory status.""" + try: + row = storage.conn.execute( + "SELECT embedding FROM chunks WHERE embedding IS NOT NULL LIMIT 1" + ).fetchone() + except Exception: + return None + if not row or not row["embedding"]: + return None + try: + raw = row["embedding"] + if isinstance(raw, (bytes, bytearray)): + # New BLOB format: 4 bytes per float32 + return len(raw) // 4 + emb = json.loads(raw) + return len(emb) if isinstance(emb, list) else None + except (json.JSONDecodeError, TypeError, Exception): + return None + + +def cleanup_legacy_state_file(db_path: PathLike) -> None: + """Remove old embedding_state.json files from earlier versions. + Safe to call repeatedly; no-op if the file is absent.""" + legacy = Path(db_path).parent / "embedding_state.json" + try: + legacy.unlink(missing_ok=True) + except Exception: + pass diff --git a/agent/memory/manager.py b/agent/memory/manager.py new file mode 100644 index 0000000..5ec2ade --- /dev/null +++ b/agent/memory/manager.py @@ -0,0 +1,555 @@ +""" +Memory manager for AgentMesh + +Provides high-level interface for memory operations +""" + +import os +from typing import List, Optional, Dict, Any +from pathlib import Path +import hashlib +from datetime import datetime, timedelta + +from agent.memory.config import MemoryConfig, get_default_memory_config +from agent.memory.storage import MemoryStorage, MemoryChunk, SearchResult +from agent.memory.chunker import TextChunker +from agent.memory.embedding import EmbeddingProvider, EmbeddingCache +from agent.memory.summarizer import MemoryFlushManager, create_memory_files_if_needed + + +class MemoryManager: + """ + Memory manager with hybrid search capabilities + + Provides long-term memory for agents with vector and keyword search + """ + + def __init__( + self, + config: Optional[MemoryConfig] = None, + embedding_provider: Optional[EmbeddingProvider] = None, + llm_model: Optional[Any] = None + ): + """ + Initialize memory manager + + Args: + config: Memory configuration (uses global config if not provided) + embedding_provider: Custom embedding provider (optional) + llm_model: LLM model for summarization (optional) + """ + self.config = config or get_default_memory_config() + + # Initialize storage + db_path = self.config.get_db_path() + self.storage = MemoryStorage(db_path) + + # Initialize chunker + self.chunker = TextChunker( + max_tokens=self.config.chunk_max_tokens, + overlap_tokens=self.config.chunk_overlap_tokens + ) + + # Embedding provider is owned by the caller (agent_initializer is the + # canonical entry point and handles legacy/explicit + state validation). + # When None is passed, memory degrades to keyword-only search instead + # of silently re-initializing a vendor here, which would bypass the + # caller's state checks and risk corrupting the index. + self.embedding_provider = embedding_provider + if self.embedding_provider is None: + from common.log import logger + logger.info( + "[MemoryManager] No embedding provider; memory will use keyword search only" + ) + + # Cache for query embeddings (avoids redundant API calls within a session) + self._embedding_cache = EmbeddingCache() + + + # Initialize memory flush manager + workspace_dir = self.config.get_workspace() + self.flush_manager = MemoryFlushManager( + workspace_dir=workspace_dir, + llm_model=llm_model + ) + + # Ensure workspace directories exist + self._init_workspace() + + self._dirty = False + + def _init_workspace(self): + """Initialize workspace directories""" + memory_dir = self.config.get_memory_dir() + memory_dir.mkdir(parents=True, exist_ok=True) + + # Create default memory files + workspace_dir = self.config.get_workspace() + create_memory_files_if_needed(workspace_dir) + + async def search( + self, + query: str, + user_id: Optional[str] = None, + max_results: Optional[int] = None, + min_score: Optional[float] = None, + include_shared: bool = True + ) -> List[SearchResult]: + """ + Search memory with hybrid search (vector + keyword) + + Args: + query: Search query + user_id: User ID for scoped search + max_results: Maximum results to return + min_score: Minimum score threshold + include_shared: Include shared memories + + Returns: + List of search results sorted by relevance + """ + max_results = max_results or self.config.max_results + min_score = min_score or self.config.min_score + + # Determine scopes + scopes = [] + if include_shared: + scopes.append("shared") + if user_id: + scopes.append("user") + + if not scopes: + return [] + + # Sync if needed + if self.config.sync_on_search and self._dirty: + await self.sync() + + from common.log import logger + + # Perform vector search (if embedding provider available). + # Failures degrade silently to keyword-only — no exception is raised. + vector_results = [] + if self.embedding_provider: + try: + provider_name = type(self.embedding_provider).__name__ + model_name = getattr(self.embedding_provider, 'model', '') + cached = self._embedding_cache.get(query, provider_name, model_name) + if cached is not None: + query_embedding = cached + else: + query_embedding = self.embedding_provider.embed_query(query) + self._embedding_cache.put(query, provider_name, model_name, query_embedding) + vector_results = self.storage.search_vector( + query_embedding=query_embedding, + user_id=user_id, + scopes=scopes, + limit=max_results * 2 # Get more candidates for merging + ) + logger.info(f"[MemoryManager] Vector search found {len(vector_results)} results for query: {query}") + except Exception as e: + logger.error( + f"[MemoryManager] Vector search failed, falling back to keyword-only: {e}" + ) + + # Perform keyword search (also runs as fallback when vector failed) + keyword_results = self.storage.search_keyword( + query=query, + user_id=user_id, + scopes=scopes, + limit=max_results * 2 + ) + logger.info(f"[MemoryManager] Keyword search found {len(keyword_results)} results for query: {query}") + + # Merge results + merged = self._merge_results( + vector_results, + keyword_results, + self.config.vector_weight, + self.config.keyword_weight + ) + + # Filter by min score and limit + filtered = [r for r in merged if r.score >= min_score] + return filtered[:max_results] + + async def add_memory( + self, + content: str, + user_id: Optional[str] = None, + scope: str = "shared", + source: str = "memory", + path: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None + ): + """ + Add new memory content + + Args: + content: Memory content + user_id: User ID for user-scoped memory + scope: Memory scope ("shared", "user", "session") + source: Memory source ("memory" or "session") + path: File path (auto-generated if not provided) + metadata: Additional metadata + """ + if not content.strip(): + return + + # Generate path if not provided + if not path: + content_hash = hashlib.md5(content.encode('utf-8')).hexdigest()[:8] + if user_id and scope == "user": + path = f"memory/users/{user_id}/memory_{content_hash}.md" + else: + path = f"memory/shared/memory_{content_hash}.md" + + # Chunk content + chunks = self.chunker.chunk_text(content) + + # Generate embeddings (if provider available) + texts = [chunk.text for chunk in chunks] + if self.embedding_provider: + embeddings = self.embedding_provider.embed_batch(texts) + else: + # No embeddings, just use None + embeddings = [None] * len(texts) + + # Create memory chunks + memory_chunks = [] + for chunk, embedding in zip(chunks, embeddings): + chunk_id = self._generate_chunk_id(path, chunk.start_line, chunk.end_line) + chunk_hash = MemoryStorage.compute_hash(chunk.text) + + memory_chunks.append(MemoryChunk( + id=chunk_id, + user_id=user_id, + scope=scope, + source=source, + path=path, + start_line=chunk.start_line, + end_line=chunk.end_line, + text=chunk.text, + embedding=embedding, + hash=chunk_hash, + metadata=metadata + )) + + # Save to storage + self.storage.save_chunks_batch(memory_chunks) + + # Update file metadata + file_hash = MemoryStorage.compute_hash(content) + self.storage.update_file_metadata( + path=path, + source=source, + file_hash=file_hash, + mtime=int(os.path.getmtime(__file__)), # Use current time + size=len(content) + ) + + async def sync(self, force: bool = False): + """ + Synchronize memory from files. + + Two-pass design to amortize embedding HTTP cost: + 1. Walk all files, chunk those whose hash changed, collect pending + chunks across files. No embedding calls yet. + 2. Run a single embed_batch over the union of pending chunks (the + provider auto-paginates by vendor cap), then persist per-file. + + For workspaces with many small files (101 files / ~1 chunk each), this + cuts ~100 HTTP calls down to ~ceil(total_chunks / vendor_cap). + + Args: + force: Force full reindex + """ + memory_dir = self.config.get_memory_dir() + workspace_dir = self.config.get_workspace() + + files_to_scan: List[tuple] = [] # (file_path, source, scope, user_id) + + memory_file = Path(workspace_dir) / "MEMORY.md" + if memory_file.exists(): + files_to_scan.append((memory_file, "memory", "shared", None)) + + if memory_dir.exists(): + for file_path in memory_dir.rglob("*.md"): + rel_parts = file_path.relative_to(workspace_dir).parts + if any(part.startswith('.') for part in rel_parts): + continue + # Dream diaries are narrative reflections produced by Deep + # Dream; their factual content has already been distilled + # into MEMORY.md. Indexing them adds noisy near-duplicates + # that crowd out the authoritative entry in retrieval. + if "dreams" in rel_parts: + continue + if "daily" in rel_parts: + if "users" in rel_parts or len(rel_parts) > 3: + user_idx = rel_parts.index("daily") + 1 + user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None + scope = "user" + else: + user_id = None + scope = "shared" + elif "users" in rel_parts: + user_idx = rel_parts.index("users") + 1 + user_id = rel_parts[user_idx] if user_idx < len(rel_parts) else None + scope = "user" + else: + user_id = None + scope = "shared" + files_to_scan.append((file_path, "memory", scope, user_id)) + + from config import conf + if conf().get("knowledge", True): + knowledge_dir = Path(workspace_dir) / "knowledge" + if knowledge_dir.exists(): + for file_path in knowledge_dir.rglob("*.md"): + files_to_scan.append((file_path, "knowledge", "shared", None)) + + # Pass 1: inline chunking + change detection. Inlined (instead of + # calling self._prepare_file_for_sync) so this method does not depend + # on any sibling helpers — keeps it robust against partial reloads + # where the class object is older than the method's source. + pending: List[Dict[str, Any]] = [] + workspace_dir_path = self.config.get_workspace() + for file_path, source, scope, user_id in files_to_scan: + try: + content = file_path.read_text(encoding='utf-8') + except Exception: + continue + file_hash = MemoryStorage.compute_hash(content) + rel_path = str(file_path.relative_to(workspace_dir_path)) + if self.storage.get_file_hash(rel_path) == file_hash: + continue + chunks = self.chunker.chunk_text(content) + if not chunks: + continue + pending.append({ + "file_path": file_path, + "rel_path": rel_path, + "source": source, + "scope": scope, + "user_id": user_id, + "file_hash": file_hash, + "chunks": chunks, + "texts": [c.text for c in chunks], + }) + + if not pending: + self._dirty = False + return + + # Pass 2: single batched embed across all pending chunks. + # CRITICAL: never touch the index until we hold valid embeddings. + # If embed_batch fails, leave the existing index intact (chunks + + # file_hash) so the next sync will retry the same files. Writing + # NULL embeddings + updating file_hash here would mark the file as + # "successfully synced" and silently strand it without vectors. + all_texts: List[str] = [] + for entry in pending: + all_texts.extend(entry["texts"]) + + if not self.embedding_provider: + # No provider configured at all (legacy keyword-only). Persist + # chunks without embeddings — this is the user's intent. + all_embeddings: List[Optional[List[float]]] = [None] * len(all_texts) + else: + try: + all_embeddings = self.embedding_provider.embed_batch(all_texts) + except Exception as e: + from common.log import logger + logger.error( + f"[MemoryManager] Batch embedding failed for {len(all_texts)} " + f"chunks across {len(pending)} files: {e}. " + f"Index left untouched; will retry on next sync." + ) + # Bail before touching storage. self._dirty stays True so + # callers know there is pending work. + return + + # Pass 3: inline persist — same self-contained reasoning as Pass 1. + cursor = 0 + for entry in pending: + n = len(entry["texts"]) + entry_embeddings = all_embeddings[cursor:cursor + n] + cursor += n + + rel_path = entry["rel_path"] + self.storage.delete_by_path(rel_path) + memory_chunks = [] + for chunk, embedding in zip(entry["chunks"], entry_embeddings): + chunk_id = self._generate_chunk_id(rel_path, chunk.start_line, chunk.end_line) + chunk_hash = MemoryStorage.compute_hash(chunk.text) + memory_chunks.append(MemoryChunk( + id=chunk_id, + user_id=entry["user_id"], + scope=entry["scope"], + source=entry["source"], + path=rel_path, + start_line=chunk.start_line, + end_line=chunk.end_line, + text=chunk.text, + embedding=embedding, + hash=chunk_hash, + metadata=None, + )) + self.storage.save_chunks_batch(memory_chunks) + stat = entry["file_path"].stat() + self.storage.update_file_metadata( + path=rel_path, + source=entry["source"], + file_hash=entry["file_hash"], + mtime=int(stat.st_mtime), + size=stat.st_size, + ) + + self._dirty = False + + def flush_memory( + self, + messages: list, + user_id: Optional[str] = None, + reason: str = "threshold", + max_messages: int = 10, + context_summary_callback=None, + ) -> bool: + """ + Flush conversation summary to daily memory file. + + Args: + messages: Conversation message list + user_id: Optional user ID + reason: "threshold" | "overflow" | "daily_summary" + max_messages: Max recent messages to include (0 = all) + context_summary_callback: Optional callback(str) invoked with the + daily summary text for in-context injection + + Returns: + True if flush was dispatched + """ + success = self.flush_manager.flush_from_messages( + messages=messages, + user_id=user_id, + reason=reason, + max_messages=max_messages, + context_summary_callback=context_summary_callback, + ) + if success: + self._dirty = True + return success + + def get_status(self) -> Dict[str, Any]: + """Get memory status""" + stats = self.storage.get_stats() + return { + 'chunks': stats['chunks'], + 'files': stats['files'], + 'workspace': str(self.config.get_workspace()), + 'dirty': self._dirty, + 'embedding_enabled': self.embedding_provider is not None, + 'embedding_provider': self.config.embedding_provider if self.embedding_provider else 'disabled', + 'embedding_model': self.config.embedding_model if self.embedding_provider else 'N/A', + 'search_mode': 'hybrid (vector + keyword)' if self.embedding_provider else 'keyword only (FTS5)' + } + + def mark_dirty(self): + """Mark memory as dirty (needs sync)""" + self._dirty = True + + def close(self): + """Close memory manager and release resources""" + self.storage.close() + + # Helper methods + + def _generate_chunk_id(self, path: str, start_line: int, end_line: int) -> str: + """Generate unique chunk ID""" + content = f"{path}:{start_line}:{end_line}" + return hashlib.md5(content.encode('utf-8')).hexdigest() + + @staticmethod + def _compute_temporal_decay(path: str, half_life_days: float = 30.0) -> float: + """ + Compute temporal decay multiplier for dated memory files. + + Inspired by OpenClaw's temporal-decay: exponential decay based on file date. + MEMORY.md and non-dated files are "evergreen" (no decay, multiplier=1.0). + Daily files like memory/2025-03-01.md decay based on age. + + Formula: multiplier = exp(-ln2/half_life * age_in_days) + """ + import re + import math + + match = re.search(r'(\d{4})-(\d{2})-(\d{2})\.md$', path) + if not match: + return 1.0 # evergreen: MEMORY.md, non-dated files + + try: + file_date = datetime( + int(match.group(1)), int(match.group(2)), int(match.group(3)) + ) + age_days = (datetime.now() - file_date).days + if age_days <= 0: + return 1.0 + + decay_lambda = math.log(2) / half_life_days + return math.exp(-decay_lambda * age_days) + except (ValueError, OverflowError): + return 1.0 + + def _merge_results( + self, + vector_results: List[SearchResult], + keyword_results: List[SearchResult], + vector_weight: float, + keyword_weight: float + ) -> List[SearchResult]: + """Merge vector and keyword search results with temporal decay for dated files""" + merged_map = {} + + for result in vector_results: + key = (result.path, result.start_line, result.end_line) + merged_map[key] = { + 'result': result, + 'vector_score': result.score, + 'keyword_score': 0.0 + } + + for result in keyword_results: + key = (result.path, result.start_line, result.end_line) + if key in merged_map: + merged_map[key]['keyword_score'] = result.score + else: + merged_map[key] = { + 'result': result, + 'vector_score': 0.0, + 'keyword_score': result.score + } + + merged_results = [] + for entry in merged_map.values(): + combined_score = ( + vector_weight * entry['vector_score'] + + keyword_weight * entry['keyword_score'] + ) + + # Apply temporal decay for dated memory files + result = entry['result'] + decay = self._compute_temporal_decay(result.path) + combined_score *= decay + + merged_results.append(SearchResult( + path=result.path, + start_line=result.start_line, + end_line=result.end_line, + score=combined_score, + snippet=result.snippet, + source=result.source, + user_id=result.user_id + )) + + merged_results.sort(key=lambda r: r.score, reverse=True) + return merged_results diff --git a/agent/memory/rebuild_index.py b/agent/memory/rebuild_index.py new file mode 100644 index 0000000..a975503 --- /dev/null +++ b/agent/memory/rebuild_index.py @@ -0,0 +1,14 @@ +""" +Backward-compatible shim for the legacy entry point: + python -m agent.memory.rebuild_index + +The implementation now lives in agent.memory.embedding.rebuild. +Prefer using `/memory rebuild-index` in chat going forward. +""" + +from agent.memory.embedding.rebuild import main + +if __name__ == "__main__": + import sys + + sys.exit(main()) diff --git a/agent/memory/service.py b/agent/memory/service.py new file mode 100644 index 0000000..055fe83 --- /dev/null +++ b/agent/memory/service.py @@ -0,0 +1,225 @@ +""" +Memory service for handling memory query operations via cloud protocol. + +Provides a unified interface for listing and reading memory files, +callable from the cloud client (LinkAI) or a future web console. + +Memory file layout (under workspace_root): + MEMORY.md -> type: global + memory/2026-02-20.md -> type: daily +""" + +import os +from datetime import datetime +from typing import Dict, List, Optional +from pathlib import Path +from common.log import logger + + +class MemoryService: + """ + High-level service for memory file queries. + Operates directly on the filesystem — no MemoryManager dependency. + """ + + def __init__(self, workspace_root: str): + """ + :param workspace_root: Workspace root directory (e.g. ~/cow) + """ + self.workspace_root = workspace_root + self.memory_dir = os.path.join(workspace_root, "memory") + + # ------------------------------------------------------------------ + # list — paginated file metadata + # ------------------------------------------------------------------ + def list_files(self, page: int = 1, page_size: int = 20, category: str = "memory") -> dict: + """ + List memory, dream, or evolution files with metadata (without content). + + Args: + category: ``"memory"`` (default) — MEMORY.md + daily files; + ``"dream"`` — dream diary files from memory/dreams/; + ``"evolution"`` — self-evolution logs from memory/evolution/ + merged with the nightly dream diaries, so + one tab shows everything the agent learned. + """ + if category == "evolution": + files = self._list_evolution_files() + elif category == "dream": + files = self._list_dream_files() + else: + files = self._list_memory_files() + + total = len(files) + start = (page - 1) * page_size + end = start + page_size + + return { + "page": page, + "page_size": page_size, + "total": total, + "list": files[start:end], + } + + def _list_memory_files(self) -> List[dict]: + """MEMORY.md + memory/*.md (newest first).""" + files: List[dict] = [] + + global_path = os.path.join(self.workspace_root, "MEMORY.md") + if os.path.isfile(global_path): + files.append(self._file_info(global_path, "MEMORY.md", "global")) + + if os.path.isdir(self.memory_dir): + daily_files = [] + for name in os.listdir(self.memory_dir): + full = os.path.join(self.memory_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + daily_files.append((name, full)) + daily_files.sort(key=lambda x: x[0], reverse=True) + for name, full in daily_files: + files.append(self._file_info(full, name, "daily")) + + return files + + def _list_dream_files(self) -> List[dict]: + """memory/dreams/*.md (newest first).""" + files: List[dict] = [] + dreams_dir = os.path.join(self.memory_dir, "dreams") + + if os.path.isdir(dreams_dir): + entries = [] + for name in os.listdir(dreams_dir): + full = os.path.join(dreams_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + entries.append((name, full)) + entries.sort(key=lambda x: x[0], reverse=True) + for name, full in entries: + files.append(self._file_info(full, name, "dream")) + + return files + + def _list_evolution_files(self) -> List[dict]: + """Self-evolution logs (memory/evolution/*.md) merged with the nightly + dream diaries (memory/dreams/*.md), newest first. + + Both are surfaced under the unified "Self-Evolution" tab. A file's + ``type`` records its origin so the reader can resolve the right dir. + """ + files: List[dict] = [] + for sub, ftype in (("evolution", "evolution"), ("dreams", "dream")): + sub_dir = os.path.join(self.memory_dir, sub) + if not os.path.isdir(sub_dir): + continue + for name in os.listdir(sub_dir): + full = os.path.join(sub_dir, name) + if os.path.isfile(full) and name.endswith(".md"): + files.append(self._file_info(full, name, ftype)) + # Sort newest first by filename (date-named); ties favor evolution. + files.sort(key=lambda f: (f["filename"], f["type"] != "evolution"), reverse=True) + return files + + # ------------------------------------------------------------------ + # content — read a single file + # ------------------------------------------------------------------ + def get_content(self, filename: str, category: str = "memory") -> dict: + """ + Read the full content of a memory or dream file. + + :param filename: File name, e.g. ``MEMORY.md``, ``2026-02-20.md`` + :param category: ``"memory"``, ``"dream"`` or ``"evolution"`` + :return: dict with ``filename`` and ``content`` + :raises FileNotFoundError: if the file does not exist + """ + path = self._resolve_path(filename, category) + if not os.path.isfile(path): + raise FileNotFoundError(f"Memory file not found: {filename}") + + with open(path, "r", encoding="utf-8") as f: + content = f.read() + + return { + "filename": filename, + "content": content, + } + + # ------------------------------------------------------------------ + # dispatch — single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a memory management action. + + :param action: ``list`` or ``content`` + :param payload: action-specific payload (supports ``category``: ``"memory"`` | ``"dream"`` | ``"evolution"``) + :return: protocol-compatible response dict + """ + payload = payload or {} + try: + if action == "list": + page = payload.get("page", 1) + page_size = payload.get("page_size", 20) + category = payload.get("category", "memory") + result_payload = self.list_files(page=page, page_size=page_size, category=category) + return {"action": action, "code": 200, "message": "success", "payload": result_payload} + + elif action == "content": + filename = payload.get("filename") + if not filename: + return {"action": action, "code": 400, "message": "filename is required", "payload": None} + category = payload.get("category", "memory") + result_payload = self.get_content(filename, category=category) + return {"action": action, "code": 200, "message": "success", "payload": result_payload} + + else: + return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + + except ValueError as e: + return {"action": action, "code": 403, "message": "invalid filename", "payload": None} + except FileNotFoundError as e: + return {"action": action, "code": 404, "message": str(e), "payload": None} + except Exception as e: + logger.error(f"[MemoryService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} + + # ------------------------------------------------------------------ + # internal helpers + # ------------------------------------------------------------------ + def _resolve_path(self, filename: str, category: str = "memory") -> str: + """ + Safely resolve a filename to its absolute path within the allowed directory. + + - ``MEMORY.md`` → ``{workspace_root}/MEMORY.md`` + - ``2026-02-20.md`` (memory) → ``{workspace_root}/memory/2026-02-20.md`` + - ``2026-02-20.md`` (dream) → ``{workspace_root}/memory/dreams/2026-02-20.md`` + - ``2026-02-20.md`` (evolution) → ``{workspace_root}/memory/evolution/2026-02-20.md`` + + Raises ValueError if the resolved path escapes the allowed directory. + """ + if filename == "MEMORY.md": + base_dir = self.workspace_root + elif category == "dream": + base_dir = os.path.join(self.memory_dir, "dreams") + elif category == "evolution": + base_dir = os.path.join(self.memory_dir, "evolution") + else: + base_dir = self.memory_dir + + resolved = os.path.realpath(os.path.join(base_dir, filename)) + allowed = os.path.realpath(base_dir) + + if resolved != allowed and not resolved.startswith(allowed + os.sep): + raise ValueError(f"Invalid filename: path traversal detected") + + return resolved + + @staticmethod + def _file_info(path: str, filename: str, file_type: str) -> dict: + """Build a file metadata dict.""" + stat = os.stat(path) + updated_at = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S") + return { + "filename": filename, + "type": file_type, + "size": stat.st_size, + "updated_at": updated_at, + } diff --git a/agent/memory/storage.py b/agent/memory/storage.py new file mode 100644 index 0000000..240ff9d --- /dev/null +++ b/agent/memory/storage.py @@ -0,0 +1,1057 @@ +""" +Storage layer for memory using SQLite + FTS5 + +Provides vector and keyword search capabilities +""" + +from __future__ import annotations +import re +import sqlite3 +import json +import hashlib +import threading +from typing import List, Dict, Optional, Any +from pathlib import Path +from dataclasses import dataclass +try: + import numpy as np + _HAS_NUMPY = True +except ImportError: + _HAS_NUMPY = False + np = None # type: ignore[assignment] + +# UPSERT (INSERT … ON CONFLICT DO UPDATE) requires SQLite ≥ 3.24.0 (2018). +# Older systems (e.g. CentOS 7 ships SQLite 3.7) fall back to INSERT OR REPLACE, +# which risks FTS5 rowid drift on chunk updates (see save_chunk docstring). +_HAS_UPSERT = sqlite3.sqlite_version_info >= (3, 24, 0) + +# --------------------------------------------------------------------------- +# CJK character ranges, compiled once at module load. +# Covers: CJK Symbols/Punctuation, Japanese kana (hiragana + katakana), +# CJK Unified Ideographs + Extension A, Korean syllables (Hangul), +# CJK Compatibility Ideographs, and CJK Extension B–F. +# --------------------------------------------------------------------------- +_CJK_RANGES = ( + r'\u3000-\u30ff' # CJK Symbols/Punctuation + Japanese kana + r'\u3400-\u9fff' # CJK Unified Ideographs (incl. Extension A) + r'\uac00-\ud7af' # Korean syllables (Hangul) + r'\uf900-\ufaff' # CJK Compatibility Ideographs + r'\U00020000-\U0002fa1f' # CJK Extension B–F +) +_RE_CONTAINS_CJK = re.compile(f'[{_CJK_RANGES}]') +_RE_CJK_WORDS = re.compile(f'[{_CJK_RANGES}]+') +_RE_TRIGRAM_TOKENS = re.compile(f'[{_CJK_RANGES}]+|[A-Za-z0-9_]+') + + +@dataclass +class MemoryChunk: + """Represents a memory chunk with text and embedding""" + id: str + user_id: Optional[str] + scope: str # "shared" | "user" | "session" + source: str # "memory" | "session" + path: str + start_line: int + end_line: int + text: str + embedding: Optional[List[float]] + hash: str + metadata: Optional[Dict[str, Any]] = None + + +@dataclass +class SearchResult: + """Search result with score and snippet""" + path: str + start_line: int + end_line: int + score: float + snippet: str + source: str + user_id: Optional[str] = None + + +class MemoryStorage: + """SQLite-based storage with FTS5 for keyword search""" + + def __init__(self, db_path: Path): + self.db_path = db_path + self.conn: Optional[sqlite3.Connection] = None + self.fts5_available = False # Track FTS5 availability + # RLock protects concurrent writes from the same process. + # SQLite WAL mode handles read/write concurrency at the file level, + # but same-process concurrent writes still need a Python-level lock. + self._lock = threading.RLock() + self._init_db() + + def _check_fts5_support(self) -> bool: + """Check if SQLite has FTS5 support""" + try: + self.conn.execute("CREATE VIRTUAL TABLE IF NOT EXISTS fts5_test USING fts5(test)") + self.conn.execute("DROP TABLE IF EXISTS fts5_test") + return True + except sqlite3.OperationalError as e: + if "no such module: fts5" in str(e): + return False + raise + + def _init_db(self): + """Initialize database with schema""" + try: + self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + + # Check FTS5 support + self.fts5_available = self._check_fts5_support() + if not _HAS_UPSERT: + from common.log import logger + logger.warning( + "[MemoryStorage] SQLite %s < 3.24 — UPSERT unavailable. " + "Falling back to INSERT OR REPLACE; FTS5 rowid may drift on " + "chunk updates (rebuild index periodically to recover).", + sqlite3.sqlite_version, + ) + if not self.fts5_available: + from common.log import logger + logger.debug("[MemoryStorage] FTS5 not available, using LIKE-based keyword search") + + # Check database integrity + try: + result = self.conn.execute("PRAGMA integrity_check").fetchone() + if result[0] != 'ok': + print(f"⚠️ Database integrity check failed: {result[0]}") + print(f" Recreating database...") + self.conn.close() + self.conn = None + # Remove corrupted database + self.db_path.unlink(missing_ok=True) + # Remove WAL files + Path(str(self.db_path) + '-wal').unlink(missing_ok=True) + Path(str(self.db_path) + '-shm').unlink(missing_ok=True) + # Reconnect to create new database + self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + except sqlite3.DatabaseError: + # Database is corrupted, recreate it + print(f"⚠️ Database is corrupted, recreating...") + if self.conn: + self.conn.close() + self.conn = None + self.db_path.unlink(missing_ok=True) + Path(str(self.db_path) + '-wal').unlink(missing_ok=True) + Path(str(self.db_path) + '-shm').unlink(missing_ok=True) + self.conn = sqlite3.connect(str(self.db_path), check_same_thread=False) + self.conn.row_factory = sqlite3.Row + + # Enable WAL mode for better concurrency + self.conn.execute("PRAGMA journal_mode=WAL") + # Set busy timeout to avoid "database is locked" errors + self.conn.execute("PRAGMA busy_timeout=5000") + except Exception as e: + print(f"⚠️ Unexpected error during database initialization: {e}") + raise + + # Create chunks table with embeddings + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS chunks ( + id TEXT PRIMARY KEY, + user_id TEXT, + scope TEXT NOT NULL DEFAULT 'shared', + source TEXT NOT NULL DEFAULT 'memory', + path TEXT NOT NULL, + start_line INTEGER NOT NULL, + end_line INTEGER NOT NULL, + text TEXT NOT NULL, + embedding TEXT, + hash TEXT NOT NULL, + metadata TEXT, + created_at INTEGER DEFAULT (strftime('%s', 'now')), + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ) + """) + + # Create indexes + self.conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_chunks_user + ON chunks(user_id) + """) + + self.conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_chunks_scope + ON chunks(scope) + """) + + self.conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_chunks_hash + ON chunks(path, hash) + """) + + # Create FTS5 virtual table + triggers (only if supported). + # Self-heal: if the previous process crashed mid-rebuild and left + # triggers pointing at a missing chunks_fts (or vice versa), wipe + # both sides and recreate cleanly. Otherwise next chunks INSERT + # will fail with "no such table: chunks_fts". + if self.fts5_available: + if self._fts5_state_inconsistent(): + from common.log import logger + logger.warning( + "[MemoryStorage] FTS5 state inconsistent (triggers/table mismatch). " + "Resetting chunks_fts to recover." + ) + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_au") + self.conn.execute("DROP TABLE IF EXISTS chunks_fts") + self.conn.commit() + self._create_fts5_objects() + + # Probe FTS5 shadow tables. The schema may be intact but the + # internal _data/_idx/_docsize blob can still be corrupt — that + # surfaces as "database disk image is malformed" on bm25 / MATCH. + # We rebuild from the chunks table when that happens; data isn't + # lost because chunks (the content table) is the source of truth. + if self._fts5_shadow_corrupt(): + from common.log import logger + logger.warning( + "[MemoryStorage] FTS5 shadow tables corrupt; rebuilding from chunks." + ) + self._rebuild_fts5_from_chunks() + + # Internal key-value store for persistent flags (e.g. backfill tracking) + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS _meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ) + """) + + # Create trigram FTS5 table for CJK / mixed-language search + self.trigram_fts5_available = False + if self.fts5_available: + try: + self.conn.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts_trigram USING fts5( + text, + id UNINDEXED, + user_id UNINDEXED, + path UNINDEXED, + source UNINDEXED, + scope UNINDEXED, + content='chunks', + content_rowid='rowid', + tokenize='trigram case_sensitive 0' + ) + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_ai + AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts_trigram(rowid, text, id, user_id, path, source, scope) + VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope); + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_ad + AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts_trigram WHERE rowid = old.rowid; + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_trigram_au + AFTER UPDATE ON chunks BEGIN + UPDATE chunks_fts_trigram + SET text=new.text, id=new.id, user_id=new.user_id, + path=new.path, source=new.source, scope=new.scope + WHERE rowid = new.rowid; + END + """) + # One-time backfill for existing rows. + # NOTE: COUNT(*) on an FTS5 content table always returns 0, so we + # use a persistent flag in _meta instead of counting trigram rows. + backfill_done = self.conn.execute( + "SELECT 1 FROM _meta WHERE key = 'trigram_backfill_done'" + ).fetchone() + chunks_count = self.conn.execute( + "SELECT COUNT(*) as c FROM chunks" + ).fetchone()['c'] + if chunks_count > 0 and not backfill_done: + self.conn.execute( + "INSERT INTO chunks_fts_trigram(chunks_fts_trigram) VALUES('rebuild')" + ) + self.conn.execute( + "INSERT OR REPLACE INTO _meta(key, value) VALUES('trigram_backfill_done', '1')" + ) + self.trigram_fts5_available = True + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] trigram FTS5 unavailable, CJK search will use LIKE fallback", exc_info=True) + self.trigram_fts5_available = False + + # Create files metadata table + self.conn.execute(""" + CREATE TABLE IF NOT EXISTS files ( + path TEXT PRIMARY KEY, + source TEXT NOT NULL DEFAULT 'memory', + hash TEXT NOT NULL, + mtime INTEGER NOT NULL, + size INTEGER NOT NULL, + updated_at INTEGER DEFAULT (strftime('%s', 'now')) + ) + """) + + self.conn.commit() + + def _fts5_state_inconsistent(self) -> bool: + """Detect a half-broken FTS5 setup (e.g. trigger exists but table doesn't).""" + try: + row = self.conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='chunks_fts'" + ).fetchone() + table_exists = row is not None + row = self.conn.execute( + "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' " + "AND name IN ('chunks_ai','chunks_ad','chunks_au')" + ).fetchone() + trigger_count = int(row[0]) if row else 0 + except Exception: + return False + # Healthy = both present (3 triggers + table) or both absent. + return table_exists != (trigger_count > 0) + + def _create_fts5_objects(self): + """Create chunks_fts virtual table and the 3 sync triggers. + + Idempotent: uses IF NOT EXISTS. Caller must hold self.conn. + """ + self.conn.execute(""" + CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5( + text, + id UNINDEXED, + user_id UNINDEXED, + path UNINDEXED, + source UNINDEXED, + scope UNINDEXED, + content='chunks', + content_rowid='rowid' + ) + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope) + VALUES (new.rowid, new.text, new.id, new.user_id, new.path, new.source, new.scope); + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE rowid = old.rowid; + END + """) + self.conn.execute(""" + CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN + UPDATE chunks_fts SET text = new.text, id = new.id, + user_id = new.user_id, path = new.path, + source = new.source, scope = new.scope + WHERE rowid = new.rowid; + END + """) + + def reset_fts5(self): + """Drop and recreate chunks_fts + triggers in one transaction. + + Used by rebuild_index to recover from FTS5 shadow-table corruption + (bm25/ORDER BY rank may raise "database disk image is malformed" + even when raw MATCH still works). + + Triggers must be dropped first; otherwise the next chunks INSERT/DELETE + on the existing connection will hit "no such table: chunks_fts". + """ + if not self.fts5_available: + return + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ai") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_ad") + self.conn.execute("DROP TRIGGER IF EXISTS chunks_au") + self.conn.execute("DROP TABLE IF EXISTS chunks_fts") + self._create_fts5_objects() + self.conn.commit() + + def _fts5_shadow_corrupt(self) -> bool: + """Probe whether bm25 over chunks_fts errors out at startup. + + Schema (table + triggers) can be intact while the underlying + FTS5 shadow blobs are malformed — typically because the previous + process crashed mid-write or wrote with a different SQLite build. + A cheap MATCH probe surfaces it immediately.""" + try: + self.conn.execute( + "SELECT bm25(chunks_fts) FROM chunks_fts WHERE chunks_fts MATCH 'a' LIMIT 1" + ).fetchone() + return False + except sqlite3.DatabaseError as e: + msg = str(e).lower() + return "malformed" in msg or "corrupt" in msg + except Exception: + # Any other error (e.g. table missing) is handled by the + # state-inconsistent path; treat as healthy here. + return False + + def _rebuild_fts5_from_chunks(self): + """Drop FTS5, recreate it, then INSERT every row from chunks. + + Safe data-wise: chunks (the content table) is the source of truth. + Done in one transaction so a crash leaves either fully old or fully + new state, not a partial rebuild. + """ + # Reset schema first; this clears any malformed shadow blobs. + self.reset_fts5() + # Re-feed content. Triggers handle future writes automatically. + self.conn.execute(""" + INSERT INTO chunks_fts(rowid, text, id, user_id, path, source, scope) + SELECT rowid, text, id, user_id, path, source, scope FROM chunks + """) + self.conn.commit() + + def save_chunk(self, chunk: MemoryChunk): + """Save a memory chunk (insert or update by id). + + Uses SQLite UPSERT (INSERT … ON CONFLICT DO UPDATE) instead of + INSERT OR REPLACE. INSERT OR REPLACE internally does DELETE+INSERT, + which changes the row's rowid. Because both FTS5 tables use + content_rowid='rowid', a new rowid would leave the old FTS index + entries pointing at a non-existent rowid and trigger + "fts5: missing row N from content table" errors. + ON CONFLICT DO UPDATE fires the AFTER UPDATE trigger (chunks_au / + chunks_trigram_au) and keeps the original rowid intact. + """ + if _HAS_UPSERT: + _SQL = """ + INSERT INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + ON CONFLICT(id) DO UPDATE SET + user_id = excluded.user_id, + scope = excluded.scope, + source = excluded.source, + path = excluded.path, + start_line = excluded.start_line, + end_line = excluded.end_line, + text = excluded.text, + embedding = excluded.embedding, + hash = excluded.hash, + metadata = excluded.metadata, + updated_at = strftime('%s', 'now') + """ + else: + _SQL = """ + INSERT OR REPLACE INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + """ + params = ( + chunk.id, chunk.user_id, chunk.scope, chunk.source, chunk.path, + chunk.start_line, chunk.end_line, chunk.text, + self._encode_embedding(chunk.embedding), + chunk.hash, + json.dumps(chunk.metadata) if chunk.metadata else None, + ) + with self._lock: + self.conn.execute(_SQL, params) + self.conn.commit() + + def save_chunks_batch(self, chunks: List[MemoryChunk]): + """Save multiple chunks in a batch (insert or update by id). + + See save_chunk for why UPSERT is used instead of INSERT OR REPLACE. + """ + if _HAS_UPSERT: + _SQL = """ + INSERT INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + ON CONFLICT(id) DO UPDATE SET + user_id = excluded.user_id, + scope = excluded.scope, + source = excluded.source, + path = excluded.path, + start_line = excluded.start_line, + end_line = excluded.end_line, + text = excluded.text, + embedding = excluded.embedding, + hash = excluded.hash, + metadata = excluded.metadata, + updated_at = strftime('%s', 'now') + """ + else: + _SQL = """ + INSERT OR REPLACE INTO chunks + (id, user_id, scope, source, path, start_line, end_line, + text, embedding, hash, metadata, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, strftime('%s', 'now')) + """ + params_list = [ + ( + c.id, c.user_id, c.scope, c.source, c.path, + c.start_line, c.end_line, c.text, + self._encode_embedding(c.embedding), + c.hash, + json.dumps(c.metadata) if c.metadata else None, + ) + for c in chunks + ] + with self._lock: + self.conn.executemany(_SQL, params_list) + self.conn.commit() + + def get_chunk(self, chunk_id: str) -> Optional[MemoryChunk]: + """Get a chunk by ID""" + row = self.conn.execute(""" + SELECT * FROM chunks WHERE id = ? + """, (chunk_id,)).fetchone() + + if not row: + return None + + return self._row_to_chunk(row) + + def search_vector( + self, + query_embedding: List[float], + user_id: Optional[str] = None, + scopes: List[str] = None, + limit: int = 10 + ) -> List[SearchResult]: + """ + Vector similarity search using numpy-vectorized cosine similarity. + All embeddings are loaded then scored in a single BLAS matrix-vector + multiply, which is ~100x faster than the pure-Python per-row loop. + """ + if scopes is None: + scopes = ["shared"] + if user_id: + scopes.append("user") + + scope_placeholders = ','.join('?' * len(scopes)) + params = list(scopes) + + if user_id: + query = f""" + SELECT * FROM chunks + WHERE scope IN ({scope_placeholders}) + AND (scope = 'shared' OR user_id = ?) + AND embedding IS NOT NULL + """ + params.append(user_id) + else: + query = f""" + SELECT * FROM chunks + WHERE scope IN ({scope_placeholders}) + AND embedding IS NOT NULL + """ + + rows = self.conn.execute(query, params).fetchall() + if not rows: + return [] + + # Parse embeddings and build a (N, D) matrix in one pass. + # New rows store BLOB bytes (np.frombuffer); legacy rows fall back to JSON. + # Filter out rows whose embedding dimension differs from the query — + # mixing dimensions would cause np.array() to produce an object array + # and matrix @ q_vec to raise ValueError. + expected_dim = len(query_embedding) + valid_rows = [] + vectors = [] + for row in rows: + vec = self._decode_embedding(row['embedding']) + if not vec: + continue + if len(vec) != expected_dim: + from common.log import logger + logger.warning( + "[MemoryStorage] Skipping chunk %s: embedding dim %d != query dim %d", + row['id'], len(vec), expected_dim + ) + continue + valid_rows.append(row) + vectors.append(vec) + + if not vectors: + return [] + + if _HAS_NUMPY: + matrix = np.array(vectors, dtype=np.float32) # (N, D) + q_vec = np.array(query_embedding, dtype=np.float32) # (D,) + + # Vectorized cosine similarity: dot(matrix, q) / (||matrix|| * ||q||) + dots = matrix @ q_vec # (N,) + row_norms = np.linalg.norm(matrix, axis=1) # (N,) + q_norm = float(np.linalg.norm(q_vec)) + denominators = row_norms * q_norm + np.maximum(denominators, 1e-10, out=denominators) # avoid div-by-zero + sims = dots / denominators # (N,) + + # Select TopK using argpartition (O(N) average), then sort only those K + k = min(limit, len(valid_rows)) + top_idx = np.argpartition(sims, -k)[-k:] + top_idx = top_idx[np.argsort(sims[top_idx])[::-1]] + + return [ + SearchResult( + path=valid_rows[i]['path'], + start_line=valid_rows[i]['start_line'], + end_line=valid_rows[i]['end_line'], + score=float(sims[i]), + snippet=self._truncate_text(valid_rows[i]['text'], 500), + source=valid_rows[i]['source'], + user_id=valid_rows[i]['user_id'] + ) + for i in top_idx + if sims[i] > 0 + ] + else: + # Pure-Python cosine similarity fallback (numpy not installed) + import math + q = query_embedding + q_norm = math.sqrt(sum(x * x for x in q)) or 1e-10 + scored = [] + for i, vec in enumerate(vectors): + dot = sum(a * b for a, b in zip(vec, q)) + v_norm = math.sqrt(sum(x * x for x in vec)) or 1e-10 + sim = dot / (v_norm * q_norm) + if sim > 0: + scored.append((sim, valid_rows[i])) + scored.sort(key=lambda x: x[0], reverse=True) + return [ + SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=sim, + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + ) + for sim, row in scored[:limit] + ] + + def search_keyword( + self, + query: str, + user_id: Optional[str] = None, + scopes: List[str] = None, + limit: int = 10 + ) -> List[SearchResult]: + """ + Keyword search using FTS5 + LIKE fallback + + Strategy: + 1. If FTS5 available and healthy: try FTS5 first + 2. Always fall back to LIKE for CJK queries + 3. If FTS5 fails OR returns empty for non-CJK, also try LIKE so a + broken FTS5 shadow table doesn't silently kill keyword search. + """ + if scopes is None: + scopes = ["shared"] + if user_id: + scopes.append("user") + + # Step 1: Standard FTS5 (unicode61) — pure ASCII queries only. + # Skipped when query contains any CJK characters: unicode61 tokenises CJK + # as individual characters without forming meaningful tokens, so it would + # match only the ASCII portion of a mixed query (e.g. "Python" from + # "Python教程") and silently discard the CJK part. Those queries go + # directly to Step 2 (trigram), which handles both ASCII and CJK together. + fts1_attempted = False + if (self.fts5_available + and not MemoryStorage._contains_cjk(query) + and MemoryStorage._build_fts_query(query)): + fts1_attempted = True + fts_results = self._search_fts5(query, user_id, scopes, limit) + if fts_results: + return fts_results + + # Step 2: Trigram FTS5 — CJK/mixed queries, plus fallback when unicode61 + # returned nothing (trigram indexes all scripts with 3-char sliding windows, + # so it can catch terms that unicode61 tokenisation misses). + if self.trigram_fts5_available and ( + MemoryStorage._contains_cjk(query) or fts1_attempted + ): + trigram_results = self._search_fts5_trigram(query, user_id, scopes, limit) + if trigram_results: + return trigram_results + + # Step 3: LIKE fallback — last resort (FTS5 unavailable, or CJK tokens + # shorter than 3 characters that trigram cannot match, e.g. a single-char query). + if not self.fts5_available or MemoryStorage._contains_cjk(query): + return self._search_like(query, user_id, scopes, limit) + + return [] + + def _search_fts5( + self, + query: str, + user_id: Optional[str], + scopes: List[str], + limit: int + ) -> List[SearchResult]: + """FTS5 full-text search""" + fts_query = self._build_fts_query(query) + if not fts_query: + return [] + + scope_placeholders = ','.join('?' * len(scopes)) + params = [fts_query] + scopes + + if user_id: + sql_query = f""" + SELECT chunks.*, bm25(chunks_fts) as rank + FROM chunks_fts + JOIN chunks ON chunks.rowid = chunks_fts.rowid + WHERE chunks_fts MATCH ? + AND chunks.scope IN ({scope_placeholders}) + AND (chunks.scope = 'shared' OR chunks.user_id = ?) + ORDER BY rank + LIMIT ? + """ + params.extend([user_id, limit]) + else: + sql_query = f""" + SELECT chunks.*, bm25(chunks_fts) as rank + FROM chunks_fts + JOIN chunks ON chunks.rowid = chunks_fts.rowid + WHERE chunks_fts MATCH ? + AND chunks.scope IN ({scope_placeholders}) + ORDER BY rank + LIMIT ? + """ + params.append(limit) + + try: + rows = self.conn.execute(sql_query, params).fetchall() + return [ + SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=self._bm25_rank_to_score(row['rank']), + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + ) + for row in rows + ] + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_fts5 failed, returning empty", exc_info=True) + return [] + + def _search_like( + self, + query: str, + user_id: Optional[str], + scopes: List[str], + limit: int + ) -> List[SearchResult]: + """LIKE-based search. + + Used as the keyword-search fallback when FTS5 is unavailable, fails, + or returns empty. Supports both CJK runs (1+ chars) and ASCII word + tokens (3+ chars) so it can serve as a true safety net for any query. + """ + # CJK runs (1+ chars, wide Unicode range) + ASCII words (3+ chars to avoid noise) + cjk_words = _RE_CJK_WORDS.findall(query) + ascii_words = [t for t in re.findall(r'[A-Za-z0-9_]+', query) if len(t) >= 3] + words = cjk_words + ascii_words + if not words: + return [] + + scope_placeholders = ','.join('?' * len(scopes)) + + # Build LIKE conditions for each word (case-insensitive for ASCII) + like_conditions = [] + params = [] + for word in words: + like_conditions.append("LOWER(text) LIKE ?") + params.append(f'%{word.lower()}%') + + where_clause = ' OR '.join(like_conditions) + params.extend(scopes) + + if user_id: + sql_query = f""" + SELECT * FROM chunks + WHERE ({where_clause}) + AND scope IN ({scope_placeholders}) + AND (scope = 'shared' OR user_id = ?) + LIMIT ? + """ + params.extend([user_id, limit]) + else: + sql_query = f""" + SELECT * FROM chunks + WHERE ({where_clause}) + AND scope IN ({scope_placeholders}) + LIMIT ? + """ + params.append(limit) + + try: + rows = self.conn.execute(sql_query, params).fetchall() + results = [] + for row in rows: + # Dynamic score: reward chunks that contain more of the query words. + # Use all tokens (CJK + ASCII) so pure-ASCII queries are not skipped. + # matched_count is always ≥1 because the WHERE clause uses OR, but + # guard defensively so unexpected zero-match rows are never surfaced. + text_lower = row['text'].lower() + matched_count = sum(1 for w in words if w.lower() in text_lower) + if matched_count == 0: + continue + score = min(0.85, 0.3 + 0.15 * matched_count) + results.append(SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=score, + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + )) + results.sort(key=lambda r: r.score, reverse=True) + return results + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_like failed, returning empty", exc_info=True) + return [] + + def delete_by_path(self, path: str): + """Delete all chunks and file metadata for a path.""" + with self._lock: + self.conn.execute("DELETE FROM chunks WHERE path = ?", (path,)) + self.conn.execute("DELETE FROM files WHERE path = ?", (path,)) + self.conn.commit() + + def get_file_hash(self, path: str) -> Optional[str]: + """Get stored file hash""" + row = self.conn.execute(""" + SELECT hash FROM files WHERE path = ? + """, (path,)).fetchone() + return row['hash'] if row else None + + def update_file_metadata(self, path: str, source: str, file_hash: str, mtime: int, size: int): + """Update file metadata""" + with self._lock: + self.conn.execute(""" + INSERT OR REPLACE INTO files (path, source, hash, mtime, size, updated_at) + VALUES (?, ?, ?, ?, ?, strftime('%s', 'now')) + """, (path, source, file_hash, mtime, size)) + self.conn.commit() + + def get_stats(self) -> Dict[str, int]: + """Get storage statistics""" + chunks_count = self.conn.execute(""" + SELECT COUNT(*) as cnt FROM chunks + """).fetchone()['cnt'] + + files_count = self.conn.execute(""" + SELECT COUNT(*) as cnt FROM files + """).fetchone()['cnt'] + + embedded_count = self.conn.execute(""" + SELECT COUNT(*) as cnt FROM chunks WHERE embedding IS NOT NULL + """).fetchone()['cnt'] + + return { + 'chunks': chunks_count, + 'files': files_count, + 'embedded': embedded_count, + } + + def close(self): + """Close database connection""" + if self.conn: + try: + self.conn.commit() # Ensure all changes are committed + self.conn.close() + self.conn = None # Mark as closed + except Exception as e: + from common.log import logger + logger.warning("[MemoryStorage] Error closing database connection: %s", e) + + def __del__(self): + """Destructor to ensure connection is closed""" + try: + self.close() + except Exception: + pass # Ignore errors during cleanup + + # Helper methods + + @staticmethod + def _encode_embedding(embedding: Optional[List[float]]) -> Optional[bytes]: + """Encode embedding as float32 BLOB bytes (~6x smaller and faster than JSON). + Falls back to struct.pack when numpy is unavailable.""" + if embedding is None: + return None + if _HAS_NUMPY: + return np.array(embedding, dtype=np.float32).tobytes() + import struct + return struct.pack(f'{len(embedding)}f', *embedding) + + @staticmethod + def _decode_embedding(raw) -> Optional[List[float]]: + """Decode embedding from BLOB bytes or legacy JSON string. + Handles both numpy and numpy-free environments.""" + if raw is None: + return None + if isinstance(raw, (bytes, bytearray)): + if _HAS_NUMPY: + return np.frombuffer(raw, dtype=np.float32).tolist() + import struct + n = len(raw) // 4 + return list(struct.unpack(f'{n}f', raw)) + # Legacy JSON format written by older versions + return json.loads(raw) + + def _row_to_chunk(self, row) -> MemoryChunk: + """Convert database row to MemoryChunk""" + return MemoryChunk( + id=row['id'], + user_id=row['user_id'], + scope=row['scope'], + source=row['source'], + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + text=row['text'], + embedding=self._decode_embedding(row['embedding']), + hash=row['hash'], + metadata=json.loads(row['metadata']) if row['metadata'] else None + ) + + @staticmethod + def _contains_cjk(text: str) -> bool: + """Check if text contains CJK or related characters (Chinese, Japanese, Korean).""" + return bool(_RE_CONTAINS_CJK.search(text)) + + @staticmethod + def _build_trigram_query(raw_query: str) -> Optional[str]: + """ + Build FTS5 MATCH query for the trigram tokenizer. + Extracts CJK sequences (including single characters) and ASCII words, + joining them with AND so all terms must appear in the matched chunk. + """ + tokens = _RE_TRIGRAM_TOKENS.findall(raw_query) + tokens = [t for t in tokens if t] + if not tokens: + return None + # Escape embedded double-quotes (FTS5 uses "" inside quoted phrases) + quoted = [f'"{t.replace(chr(34), chr(34)*2)}"' for t in tokens] + return ' AND '.join(quoted) + + def _search_fts5_trigram( + self, + query: str, + user_id: Optional[str], + scopes: List[str], + limit: int + ) -> List[SearchResult]: + """Trigram FTS5 search — handles CJK and mixed queries with BM25 ranking.""" + trigram_query = self._build_trigram_query(query) + if not trigram_query: + return [] + + scope_placeholders = ','.join('?' * len(scopes)) + params = [trigram_query] + list(scopes) + + if user_id: + sql = f""" + SELECT chunks.*, bm25(chunks_fts_trigram) as rank + FROM chunks_fts_trigram + JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid + WHERE chunks_fts_trigram MATCH ? + AND chunks.scope IN ({scope_placeholders}) + AND (chunks.scope = 'shared' OR chunks.user_id = ?) + ORDER BY rank + LIMIT ? + """ + params.extend([user_id, limit]) + else: + sql = f""" + SELECT chunks.*, bm25(chunks_fts_trigram) as rank + FROM chunks_fts_trigram + JOIN chunks ON chunks.rowid = chunks_fts_trigram.rowid + WHERE chunks_fts_trigram MATCH ? + AND chunks.scope IN ({scope_placeholders}) + ORDER BY rank + LIMIT ? + """ + params.append(limit) + + try: + rows = self.conn.execute(sql, params).fetchall() + return [ + SearchResult( + path=row['path'], + start_line=row['start_line'], + end_line=row['end_line'], + score=self._bm25_rank_to_score(row['rank']), + snippet=self._truncate_text(row['text'], 500), + source=row['source'], + user_id=row['user_id'] + ) + for row in rows + ] + except Exception: + from common.log import logger + logger.warning("[MemoryStorage] _search_fts5_trigram failed, returning empty", exc_info=True) + return [] + + @staticmethod + def _build_fts_query(raw_query: str) -> Optional[str]: + """ + Build FTS5 query from raw text + + Works best for English and word-based languages. + For CJK characters, LIKE search will be used as fallback. + """ + # Extract words (primarily English words and numbers) + tokens = re.findall(r'[A-Za-z0-9_]+', raw_query) + if not tokens: + return None + + # Quote tokens for exact matching + quoted = [f'"{t}"' for t in tokens] + # Use OR for more flexible matching + return ' OR '.join(quoted) + + @staticmethod + def _bm25_rank_to_score(rank: float) -> float: + """Convert SQLite BM25 rank to a [0, 1) relevance score. + + SQLite's bm25() returns a non-positive float (0 or negative). + More negative = more relevant. max(0, rank) would clip every + negative value to 0, making every score 1/(1+0) = 1.0 and + destroying all ranking information. + + abs(rank) / (1 + abs(rank)) maps the absolute relevance magnitude + to [0, 1): larger |rank| (stronger match) → score closer to 1. + """ + if rank is None: + return 0.0 + # Add a floor of 0.3 so any FTS5 match always exceeds typical + # min_score thresholds (default 0.1). Small-corpus ranks close to + # 0 would otherwise produce score≈0 and be filtered out downstream. + return 0.3 + 0.69 * (abs(rank) / (1.0 + abs(rank))) + + @staticmethod + def _truncate_text(text: str, max_chars: int) -> str: + """Truncate text to max characters""" + if len(text) <= max_chars: + return text + return text[:max_chars] + "..." + + @staticmethod + def compute_hash(content: str) -> str: + """Compute SHA256 hash of content""" + return hashlib.sha256(content.encode('utf-8')).hexdigest() diff --git a/agent/memory/summarizer.py b/agent/memory/summarizer.py new file mode 100644 index 0000000..49f47ad --- /dev/null +++ b/agent/memory/summarizer.py @@ -0,0 +1,857 @@ +""" +Memory flush manager with Deep Dream distillation + +Handles memory persistence when conversation context is trimmed or overflows: +- Uses LLM to summarize discarded messages into concise daily records +- Writes to daily memory files (lazy creation) +- Deduplicates trim flushes to avoid repeated writes +- Runs summarization asynchronously to avoid blocking normal replies +- Deep Dream: periodically distills daily memories → refined MEMORY.md + dream diary +""" + +import threading +from typing import Optional, Callable, Any, List, Dict +from pathlib import Path +from datetime import datetime +from common.log import logger + + +SUMMARIZE_SYSTEM_PROMPT_ZH = """你是一个对话记录助手。请将对话内容归纳为当天的日常记录。 + +## 要求 + +按「事件」维度归纳发生的事,不要按对话轮次逐条记录: +- 每条一行,用 "- " 开头 +- 合并同一件事的多轮对话 +- 只记录有意义的事件,忽略闲聊和问候 +- 保留关键的决策、结论和待办事项 + +当对话没有任何记录价值(仅含问候或无意义内容),直接回复"无"。""" + +SUMMARIZE_SYSTEM_PROMPT_EN = """You are a conversation-logging assistant. Summarize the conversation into a daily record. + +## Requirements + +Summarize by "event", not turn by turn: +- One item per line, starting with "- " +- Merge multiple turns about the same thing +- Only record meaningful events; ignore small talk and greetings +- Keep key decisions, conclusions and to-dos + +If the conversation has no record value (only greetings or meaningless content), reply with exactly "None".""" + +SUMMARIZE_USER_PROMPT_ZH = """请归纳以下对话的日常记录: + +{conversation}""" + +SUMMARIZE_USER_PROMPT_EN = """Summarize the daily record of the following conversation: + +{conversation}""" + +# --------------------------------------------------------------------------- +# Deep Dream prompts — distill daily memories → MEMORY.md + dream diary +# --------------------------------------------------------------------------- + +DREAM_SYSTEM_PROMPT_ZH = """你是一个记忆整理助手,负责定期整理用户的长期记忆。 + +你将收到两份材料: +1. **当前长期记忆** — MEMORY.md 的全部现有内容 +2. **今日日记** — 当天的日常记录 + +MEMORY.md 会注入每次对话的系统提示词中,因此必须保持精炼,只存放有价值和值得记忆的内容。 + +**重要:只能基于提供的材料进行整理,严禁编造、推测或添加材料中不存在的信息。** + +## 任务 + +### Part 1: 更新后的长期记忆([MEMORY]) + +在现有记忆基础上进行整理和提炼,输出完整的更新后内容: +- **合并提炼**:将含义相近的多条合并为一条高密度表述,而非简单罗列 +- **新增萃取**:从今日日记中提取值得永久记住的新信息(偏好、决策、人物、规则、经验) +- **冲突更新**:当新信息与旧条目矛盾时,以新信息为准,替换旧条目 +- **清理无效**:删除临时性记录、空白条目、格式残留、无意义、重复内容等 +- **删除冗余**:已被更精炼表述涵盖的旧条目应删除,避免信息重复 +- 每条一行,用 "- " 开头,不带日期前缀 +- 可用 "## 标题" 对相关条目分组,使结构更清晰 +- 目标:控制在 50 条以内,每条尽量一句话概括 + +### Part 2: 梦境日记([DREAM]) + +用简洁的叙事风格写一篇短日记,记录这次整理的发现,保持格式美观易读: +- 发现了哪些重复或矛盾 +- 从日记中提取了什么新洞察 +- 做了哪些清理和优化 +- 整体感受和观察 + +## 输出格式(严格遵守) + +``` +[MEMORY] +- 记忆条目1 +- 记忆条目2 +... + +[DREAM] +梦境日记内容... +```""" + +DREAM_SYSTEM_PROMPT_EN = """You are a memory-curation assistant that periodically organizes the user's long-term memory. + +You will receive two inputs: +1. **Current long-term memory** — the full existing content of MEMORY.md +2. **Today's diary** — the daily records + +MEMORY.md is injected into the system prompt of every conversation, so it must stay concise and hold only valuable, memory-worthy content. + +**Important: organize strictly based on the provided material. Never fabricate, infer, or add information not present in it.** + +## Tasks + +### Part 1: Updated long-term memory ([MEMORY]) + +Organize and distill on top of the existing memory, and output the complete updated content: +- **Merge & distill**: combine semantically similar items into one dense statement rather than listing them +- **Extract new**: pull memory-worthy new info from today's diary (preferences, decisions, people, rules, lessons) +- **Resolve conflicts**: when new info contradicts an old item, prefer the new and replace the old +- **Clean invalid**: remove temporary notes, blank items, formatting residue, meaningless or duplicate content +- **Drop redundancy**: delete old items already covered by a more concise statement +- One item per line, starting with "- ", without a date prefix +- You may group related items under "## headings" for clarity +- Goal: keep under 50 items, each ideally a single sentence + +### Part 2: Dream diary ([DREAM]) + +Write a short diary in a concise narrative style recording what this curation found, keep it clean and readable: +- Which duplicates or conflicts were found +- What new insights were extracted from the diary +- What cleanup and optimization was done +- Overall feelings and observations + +## Output format (follow strictly) + +``` +[MEMORY] +- memory item 1 +- memory item 2 +... + +[DREAM] +dream diary content... +```""" + +DREAM_USER_PROMPT_ZH = """## 当前长期记忆(MEMORY.md) + +{memory_content} + +## 近期日记(最近 {days} 天) + +{daily_content}""" + +DREAM_USER_PROMPT_EN = """## Current long-term memory (MEMORY.md) + +{memory_content} + +## Recent diary (last {days} days) + +{daily_content}""" + + +def _is_en() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + + +def _summarize_system_prompt() -> str: + return SUMMARIZE_SYSTEM_PROMPT_EN if _is_en() else SUMMARIZE_SYSTEM_PROMPT_ZH + + +def _summarize_user_prompt() -> str: + return SUMMARIZE_USER_PROMPT_EN if _is_en() else SUMMARIZE_USER_PROMPT_ZH + + +def _dream_system_prompt() -> str: + return DREAM_SYSTEM_PROMPT_EN if _is_en() else DREAM_SYSTEM_PROMPT_ZH + + +def _dream_user_prompt() -> str: + return DREAM_USER_PROMPT_EN if _is_en() else DREAM_USER_PROMPT_ZH + + +def _is_empty_sentinel(text: str) -> bool: + """Match the "no record value" sentinel in both zh ("无") and en ("None").""" + if not text: + return True + s = text.strip() + return s == "" or s == "无" or s.lower() == "none" + + + +class MemoryFlushManager: + """ + Manages memory flush operations. + + Flush is triggered by agent_stream in two scenarios: + 1. Context trim: _trim_messages discards old turns → flush discarded content + 2. Context overflow: API rejects request → emergency flush before clearing + + Additionally, create_daily_summary() can be called by scheduler for end-of-day summaries. + """ + + def __init__( + self, + workspace_dir: Path, + llm_model: Optional[Any] = None, + ): + self.workspace_dir = workspace_dir + self.llm_model = llm_model + + self.memory_dir = workspace_dir / "memory" + self.memory_dir.mkdir(parents=True, exist_ok=True) + + self.last_flush_timestamp: Optional[datetime] = None + self._trim_flushed_hashes: set = set() # Content hashes of already-flushed messages + self._last_flushed_content_hash: str = "" # Content hash at last flush, for daily dedup + self._last_dream_input_hash: str = "" # "{date}:{daily_hash}" of last dream, for dedup + self._last_flush_thread: Optional[threading.Thread] = None + + def get_today_memory_file(self, user_id: Optional[str] = None, ensure_exists: bool = False) -> Path: + """Get today's memory file path: memory/YYYY-MM-DD.md""" + today = datetime.now().strftime("%Y-%m-%d") + + if user_id: + user_dir = self.memory_dir / "users" / user_id + if ensure_exists: + user_dir.mkdir(parents=True, exist_ok=True) + today_file = user_dir / f"{today}.md" + else: + today_file = self.memory_dir / f"{today}.md" + + if ensure_exists and not today_file.exists(): + today_file.parent.mkdir(parents=True, exist_ok=True) + today_file.write_text(f"# Daily Memory: {today}\n\n") + + return today_file + + def get_main_memory_file(self, user_id: Optional[str] = None) -> Path: + """Get main memory file path: MEMORY.md (workspace root)""" + if user_id: + user_dir = self.memory_dir / "users" / user_id + user_dir.mkdir(parents=True, exist_ok=True) + return user_dir / "MEMORY.md" + else: + return Path(self.workspace_dir) / "MEMORY.md" + + def get_status(self) -> dict: + return { + 'last_flush_time': self.last_flush_timestamp.isoformat() if self.last_flush_timestamp else None, + 'today_file': str(self.get_today_memory_file()), + 'main_file': str(self.get_main_memory_file()) + } + + # ---- Flush execution (called by agent_stream or scheduler) ---- + + def flush_from_messages( + self, + messages: List[Dict], + user_id: Optional[str] = None, + reason: str = "trim", + max_messages: int = 0, + context_summary_callback: Optional[Callable[[str], None]] = None, + ) -> bool: + """ + Asynchronously summarize and flush messages to daily memory. + + Deduplication runs synchronously, then LLM summarization + file write + run in a background thread so the main reply flow is never blocked. + + If *context_summary_callback* is provided, it is called with the + [DAILY] portion of the LLM summary once available. The caller can use + this to inject the summary into the live message list for context + continuity — one LLM call serves both disk persistence and in-context + injection. + """ + try: + # Strip scheduler-injected pairs before any further processing. + # These messages already serve as short-term context inside the + # receiver session; promoting them into long-term daily memory + # produces low-value flat logs (e.g. "11:28 price=1013, normal / + # 11:58 price=1013, normal / ...") and wastes summarisation tokens. + messages = self._strip_scheduler_pairs(messages) + if not messages: + return False + + import hashlib + deduped = [] + for m in messages: + text = self._extract_text_from_content(m.get("content", "")) + if not text or not text.strip(): + continue + h = hashlib.md5(text.encode("utf-8")).hexdigest() + if h not in self._trim_flushed_hashes: + self._trim_flushed_hashes.add(h) + deduped.append(m) + if not deduped: + return False + + import copy + snapshot = copy.deepcopy(deduped) + thread = threading.Thread( + target=self._flush_worker, + args=(snapshot, user_id, reason, max_messages, context_summary_callback), + daemon=True, + ) + thread.start() + logger.info(f"[MemoryFlush] Async flush dispatched (reason={reason}, msgs={len(snapshot)})") + self._last_flush_thread = thread + return True + + except Exception as e: + logger.warning(f"[MemoryFlush] Failed to dispatch flush (reason={reason}): {e}") + return False + + def _flush_worker( + self, + messages: List[Dict], + user_id: Optional[str], + reason: str, + max_messages: int, + context_summary_callback: Optional[Callable[[str], None]] = None, + ): + """Background worker: summarize with LLM, write daily memory file.""" + try: + raw_summary = self._summarize_messages(messages, max_messages) + if _is_empty_sentinel(raw_summary): + logger.info(f"[MemoryFlush] No valuable content to flush (reason={reason})") + return + + # Strip legacy [DAILY]/[MEMORY] markers if model still outputs them + daily_part = self._clean_summary_output(raw_summary) + if not daily_part: + return + + # --- Write daily memory --- + daily_file = ensure_daily_memory_file(self.workspace_dir, user_id) + + headers = { + "overflow": f"## Context Overflow Recovery ({datetime.now().strftime('%H:%M')})", + "trim": f"## Trimmed Context ({datetime.now().strftime('%H:%M')})", + "daily_summary": f"## Daily Summary ({datetime.now().strftime('%H:%M')})", + } + header = headers.get(reason, f"## Session Notes ({datetime.now().strftime('%H:%M')})") + + with open(daily_file, "a", encoding="utf-8") as f: + f.write(f"\n{header}\n\n{daily_part}\n") + + logger.info(f"[MemoryFlush] Wrote daily memory to {daily_file.name} (reason={reason}, chars={len(daily_part)})") + + # --- Inject context summary into live messages (if callback provided) --- + if context_summary_callback: + try: + context_summary_callback(daily_part) + except Exception as e: + logger.warning(f"[MemoryFlush] Context summary callback failed: {e}") + + self.last_flush_timestamp = datetime.now() + + except Exception as e: + logger.warning(f"[MemoryFlush] Async flush failed (reason={reason}): {e}") + + @staticmethod + def _clean_summary_output(raw: str) -> str: + """Strip legacy [DAILY]/[MEMORY] markers if present, return clean daily text.""" + raw = raw.strip() + if _is_empty_sentinel(raw): + return "" + + # Strip [DAILY] marker + if "[DAILY]" in raw: + start = raw.index("[DAILY]") + len("[DAILY]") + end = raw.index("[MEMORY]") if "[MEMORY]" in raw else len(raw) + raw = raw[start:end].strip() + + # Remove stray [MEMORY] section entirely + if "[MEMORY]" in raw: + raw = raw[:raw.index("[MEMORY]")].strip() + + # Remove markdown code fences + raw = raw.replace("```", "").strip() + + return raw + + def create_daily_summary( + self, + messages: List[Dict], + user_id: Optional[str] = None + ) -> bool: + """ + Generate end-of-day summary. Called by daily timer. + Skips if messages haven't changed since last flush. + """ + import hashlib + content = "".join( + self._extract_text_from_content(m.get("content", "")) + for m in messages + ) + content_hash = hashlib.md5(content.encode("utf-8")).hexdigest() + if content_hash == self._last_flushed_content_hash: + logger.debug("[MemoryFlush] Daily summary skipped: no new content since last flush") + return False + self._last_flushed_content_hash = content_hash + return self.flush_from_messages( + messages=messages, + user_id=user_id, + reason="daily_summary", + max_messages=0, + ) + + # ---- Deep Dream (memory distillation) ---- + + def deep_dream(self, user_id: Optional[str] = None, lookback_days: int = 1, force: bool = False) -> bool: + """ + Distill recent daily memories into MEMORY.md and generate a dream diary. + + Args: + lookback_days: How many days of daily files to read (default 1 for scheduled, 3 for manual) + force: Skip input-hash dedup check (used by manual /memory dream trigger) + """ + # Config guard for scheduled runs. Manual trigger (force=True) always + # runs since it is an explicit user action. + if not force: + try: + from config import conf + if not conf().get("deep_dream_enabled", True): + logger.info("[DeepDream] deep_dream_enabled=false, skipping") + return False + except Exception: + pass + + if not self.llm_model: + logger.warning("[DeepDream] No LLM model available, skipping") + return False + + logger.info(f"[DeepDream] Starting memory distillation (lookback={lookback_days} days)") + + # Collect materials + memory_content = self._read_main_memory(user_id) + daily_content, has_content = self._read_recent_dailies(user_id, lookback_days) + + if not has_content: + logger.info("[DeepDream] No recent daily records, skipping to preserve existing MEMORY.md") + return False + + # Dedup: skip if same daily content already dreamed today. + # Note: only hash daily_content (not memory_content), because deep_dream + # itself rewrites MEMORY.md as a side effect, which would otherwise + # invalidate the hash on every subsequent call within the same window. + import hashlib + daily_hash = hashlib.md5(daily_content.encode("utf-8")).hexdigest() + today_str = datetime.now().strftime("%Y-%m-%d") + dedup_key = f"{today_str}:{daily_hash}" + if not force and dedup_key == self._last_dream_input_hash: + logger.info("[DeepDream] Already dreamed today with same daily content, skipping") + return False + self._last_dream_input_hash = dedup_key + + logger.info( + f"[DeepDream] Materials collected: " + f"MEMORY.md={len(memory_content)} chars, " + f"daily={len(daily_content)} chars" + ) + + # Call LLM for distillation + import time as _time + t0 = _time.monotonic() + try: + user_msg = _dream_user_prompt().format( + memory_content=memory_content or "(empty)", + days=lookback_days, + daily_content=daily_content or "(no recent daily records)", + ) + from agent.protocol.models import LLMRequest + # No output cap: the prompt already keeps MEMORY.md concise (~50 + # items), so a hard max_tokens would only risk truncating a large + # rewrite. Let the model use its default output budget. + request = LLMRequest( + messages=[{"role": "user", "content": user_msg}], + temperature=0.3, + stream=False, + system=_dream_system_prompt(), + ) + response = self.llm_model.call(request) + raw = self._extract_response_text(response) + elapsed = _time.monotonic() - t0 + if not raw or not raw.strip(): + logger.warning(f"[DeepDream] LLM returned empty response ({elapsed:.1f}s)") + return False + logger.info(f"[DeepDream] LLM distillation completed ({elapsed:.1f}s, {len(raw)} chars)") + except Exception as e: + elapsed = _time.monotonic() - t0 + logger.warning(f"[DeepDream] LLM call failed ({elapsed:.1f}s): {e}") + return False + + # Parse [MEMORY] and [DREAM] sections + new_memory, dream_diary = self._parse_dream_output(raw) + + if not new_memory: + logger.warning("[DeepDream] No [MEMORY] section in LLM output, skipping overwrite") + return False + + # Overwrite MEMORY.md + try: + main_file = self.get_main_memory_file(user_id) + old_size = len(memory_content) + main_file.write_text(new_memory + "\n", encoding="utf-8") + logger.info( + f"[DeepDream] Updated MEMORY.md " + f"({old_size} → {len(new_memory)} chars)" + ) + except Exception as e: + logger.warning(f"[DeepDream] Failed to write MEMORY.md: {e}") + return False + + # Write dream diary + if dream_diary: + try: + self._write_dream_diary(dream_diary, user_id) + except Exception as e: + logger.warning(f"[DeepDream] Failed to write dream diary: {e}") + + logger.info("[DeepDream] ✅ Deep Dream completed successfully") + return True + + def _read_main_memory(self, user_id: Optional[str] = None) -> str: + """Read current MEMORY.md content.""" + main_file = self.get_main_memory_file(user_id) + if main_file.exists(): + return main_file.read_text(encoding="utf-8").strip() + return "" + + def _read_recent_dailies( + self, user_id: Optional[str] = None, lookback_days: int = 1 + ) -> tuple: + """ + Read recent daily memory files. + + Returns: + (combined_text, has_content) tuple + """ + from datetime import timedelta + + parts = [] + has_content = False + today = datetime.now().date() + + for offset in range(lookback_days): + day = today - timedelta(days=offset) + date_str = day.strftime("%Y-%m-%d") + if user_id: + daily_file = self.memory_dir / "users" / user_id / f"{date_str}.md" + else: + daily_file = self.memory_dir / f"{date_str}.md" + + if daily_file.exists(): + content = daily_file.read_text(encoding="utf-8").strip() + if content: + parts.append(f"### {date_str}\n\n{content}") + has_content = True + else: + parts.append(f"### {date_str}\n\n(no records)") + + return "\n\n".join(parts), has_content + + @staticmethod + def _parse_dream_output(raw: str) -> tuple: + """Parse LLM output into (new_memory, dream_diary).""" + raw = raw.strip().replace("```", "") + new_memory = "" + dream_diary = "" + + if "[MEMORY]" in raw: + start = raw.index("[MEMORY]") + len("[MEMORY]") + end = raw.index("[DREAM]") if "[DREAM]" in raw else len(raw) + new_memory = raw[start:end].strip() + + if "[DREAM]" in raw: + start = raw.index("[DREAM]") + len("[DREAM]") + dream_diary = raw[start:].strip() + + return new_memory, dream_diary + + def _write_dream_diary(self, content: str, user_id: Optional[str] = None): + """Write dream diary to memory/dreams/YYYY-MM-DD.md.""" + dreams_dir = self.memory_dir / "dreams" + if user_id: + dreams_dir = self.memory_dir / "users" / user_id / "dreams" + dreams_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now().strftime("%Y-%m-%d") + diary_file = dreams_dir / f"{today}.md" + diary_file.write_text( + f"# Dream Diary: {today}\n\n{content}\n", + encoding="utf-8", + ) + logger.info(f"[DeepDream] Wrote dream diary to {diary_file}") + + # ---- Internal helpers ---- + + def _summarize_messages(self, messages: List[Dict], max_messages: int = 0) -> str: + """ + Summarize conversation messages using LLM. + Returns empty string if LLM deems content not worth recording. + Rule-based fallback only used when LLM call raises an exception. + """ + conversation_text = self._format_conversation_for_summary(messages, max_messages) + if not conversation_text.strip(): + return "" + + if self.llm_model: + try: + summary = self._call_llm_for_summary(conversation_text) + if not _is_empty_sentinel(summary): + return summary.strip() + logger.info("[MemoryFlush] LLM returned empty sentinel, skipping write") + return "" + except Exception as e: + logger.warning(f"[MemoryFlush] LLM summarization failed, using fallback: {e}") + return self._extract_summary_fallback(messages, max_messages) + else: + logger.info("[MemoryFlush] No LLM model available, using rule-based fallback") + return self._extract_summary_fallback(messages, max_messages) + + def _format_conversation_for_summary(self, messages: List[Dict], max_messages: int = 0) -> str: + """Format messages into readable conversation text for LLM summarization.""" + msgs = messages if max_messages == 0 else messages[-max_messages * 2:] + lines = [] + for msg in msgs: + role = msg.get("role", "") + text = self._extract_text_from_content(msg.get("content", "")) + if not text or not text.strip(): + continue + text = text.strip() + if role == "user": + lines.append(f"用户: {text[:500]}") + elif role == "assistant": + lines.append(f"助手: {text[:500]}") + return "\n".join(lines) + + @staticmethod + def _extract_response_text(response) -> str: + """ + Extract text from LLM response regardless of format. + + Handles: + - Generator (MiniMax _handle_sync_response yields Claude-format dicts) + - Claude format: {"role":"assistant","content":[{"type":"text","text":"..."}]} + - OpenAI format: {"choices":[{"message":{"content":"..."}}]} + - OpenAI SDK response object with .choices attribute + """ + import types + + # Unwrap generator — consume first yielded item + if isinstance(response, types.GeneratorType): + try: + response = next(response) + except StopIteration: + return "" + + if not response: + return "" + + if isinstance(response, dict): + # Check for error + if response.get("error"): + raise RuntimeError(response.get("message", "LLM call failed")) + + # Claude format: content is a list of blocks + content = response.get("content") + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + return block.get("text", "") + + # OpenAI format + choices = response.get("choices", []) + if choices: + return choices[0].get("message", {}).get("content", "") + + # OpenAI SDK response object + if hasattr(response, "choices") and response.choices: + return response.choices[0].message.content or "" + + return "" + + def _call_llm_for_summary(self, conversation_text: str) -> str: + """Call LLM to generate a concise summary of the conversation.""" + from agent.protocol.models import LLMRequest + + request = LLMRequest( + messages=[{"role": "user", "content": _summarize_user_prompt().format(conversation=conversation_text)}], + temperature=0, + max_tokens=500, + stream=False, + system=_summarize_system_prompt(), + ) + + response = self.llm_model.call(request) + return self._extract_response_text(response) + + @staticmethod + def _extract_first_meaningful_line(text: str, max_len: int = 120) -> str: + """Extract the first meaningful line from assistant reply, skipping markdown noise.""" + import re + for line in text.split("\n"): + line = line.strip() + if not line: + continue + # Skip markdown headings, horizontal rules, code fences, pure emoji/symbols + if re.match(r'^(#{1,4}\s|```|---|\*\*\*|[-*]\s*$|[^\w\u4e00-\u9fff]{1,5}$)', line): + continue + # Strip leading markdown bold/emoji decorations + cleaned = re.sub(r'^[\*#>\-\s]+', '', line).strip() + cleaned = re.sub(r'^[\U0001f300-\U0001f9ff\u2600-\u27bf\s]+', '', cleaned).strip() + if len(cleaned) >= 5: + return cleaned[:max_len] + return text.split("\n")[0].strip()[:max_len] + + @staticmethod + def _extract_summary_fallback(messages: List[Dict], max_messages: int = 0) -> str: + """ + Rule-based summary of discarded messages. + Format: "用户问了X; 助手回答了Y" per event, compact and readable. + """ + msgs = messages if max_messages == 0 else messages[-max_messages * 2:] + + events: List[str] = [] + current_user_text = "" + for msg in msgs: + role = msg.get("role", "") + text = MemoryFlushManager._extract_text_from_content(msg.get("content", "")) + if not text or not text.strip(): + continue + text = text.strip() + + if role == "user": + if len(text) <= 3: + continue + current_user_text = text[:120] + elif role == "assistant" and current_user_text: + reply_summary = MemoryFlushManager._extract_first_meaningful_line(text) + if reply_summary: + events.append(f"- 用户: {current_user_text} → 回复: {reply_summary}") + else: + events.append(f"- 用户: {current_user_text}") + current_user_text = "" + + if current_user_text: + events.append(f"- 用户: {current_user_text}") + + return "\n".join(events[:10]) + + @staticmethod + def _extract_text_from_content(content) -> str: + """Extract plain text from message content (string or content blocks).""" + if isinstance(content, str): + return content + if isinstance(content, list): + parts = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(block.get("text", "")) + elif isinstance(block, str): + parts.append(block) + return "\n".join(parts) + return "" + + @classmethod + def _strip_scheduler_pairs(cls, messages: List[Dict]) -> List[Dict]: + """Drop scheduler-injected user/assistant pairs from a flush batch. + + A scheduler user message starts with the ``[SCHEDULED]`` marker + (written by ``AgentBridge.remember_scheduled_output``); the message + immediately following it (if it is an assistant turn) is its paired + output and is dropped together. Regular user/assistant turns and + any tool_use / tool_result blocks are preserved as-is. + """ + if not messages: + return messages + + SCHEDULED_PREFIX = "[SCHEDULED]" + result = [] + skip_next_assistant = False + for msg in messages: + if not isinstance(msg, dict): + result.append(msg) + skip_next_assistant = False + continue + role = msg.get("role") + if skip_next_assistant and role == "assistant": + skip_next_assistant = False + continue + skip_next_assistant = False + if role == "user": + text = cls._extract_text_from_content(msg.get("content", "")) + if text.lstrip().startswith(SCHEDULED_PREFIX): + skip_next_assistant = True + continue + result.append(msg) + return result + + +def create_memory_files_if_needed(workspace_dir: Path, user_id: Optional[str] = None): + """ + Create essential memory files if they don't exist. + Only creates MEMORY.md; daily files are created lazily on first write. + + Args: + workspace_dir: Workspace directory + user_id: Optional user ID for user-specific files + """ + memory_dir = workspace_dir / "memory" + memory_dir.mkdir(parents=True, exist_ok=True) + + # Create main MEMORY.md in workspace root (always needed for bootstrap) + if user_id: + user_dir = memory_dir / "users" / user_id + user_dir.mkdir(parents=True, exist_ok=True) + main_memory = user_dir / "MEMORY.md" + else: + main_memory = Path(workspace_dir) / "MEMORY.md" + + if not main_memory.exists(): + main_memory.write_text("") + + +def ensure_daily_memory_file(workspace_dir: Path, user_id: Optional[str] = None) -> Path: + """ + Ensure today's daily memory file exists, creating it only when actually needed. + Called lazily before first write to daily memory. + + Args: + workspace_dir: Workspace directory + user_id: Optional user ID for user-specific files + + Returns: + Path to today's memory file + """ + memory_dir = workspace_dir / "memory" + memory_dir.mkdir(parents=True, exist_ok=True) + + today = datetime.now().strftime("%Y-%m-%d") + if user_id: + user_dir = memory_dir / "users" / user_id + user_dir.mkdir(parents=True, exist_ok=True) + today_memory = user_dir / f"{today}.md" + else: + today_memory = memory_dir / f"{today}.md" + + if not today_memory.exists(): + today_memory.write_text( + f"# Daily Memory: {today}\n\n" + ) + + return today_memory diff --git a/agent/prompt/__init__.py b/agent/prompt/__init__.py new file mode 100644 index 0000000..29295e9 --- /dev/null +++ b/agent/prompt/__init__.py @@ -0,0 +1,13 @@ +""" +Agent Prompt Module - 系统提示词构建模块 +""" + +from .builder import PromptBuilder, build_agent_system_prompt +from .workspace import ensure_workspace, load_context_files + +__all__ = [ + 'PromptBuilder', + 'build_agent_system_prompt', + 'ensure_workspace', + 'load_context_files', +] diff --git a/agent/prompt/builder.py b/agent/prompt/builder.py new file mode 100644 index 0000000..538d715 --- /dev/null +++ b/agent/prompt/builder.py @@ -0,0 +1,760 @@ +""" +System Prompt Builder - 系统提示词构建器 + +实现模块化的系统提示词构建,支持工具、技能、记忆等多个子系统 +""" + +from __future__ import annotations +import os +from typing import List, Dict, Optional, Any +from dataclasses import dataclass + +from common.log import logger +from config import conf + + +@dataclass +class ContextFile: + """A context file (path + content).""" + path: str + content: str + + +class PromptBuilder: + """System prompt builder.""" + + def __init__(self, workspace_dir: str, language: str = "zh"): + """ + 初始化提示词构建器 + + Args: + workspace_dir: 工作空间目录 + language: 语言 ("zh" 或 "en") + """ + self.workspace_dir = workspace_dir + self.language = language + + def build( + self, + base_persona: Optional[str] = None, + user_identity: Optional[Dict[str, str]] = None, + tools: Optional[List[Any]] = None, + context_files: Optional[List[ContextFile]] = None, + skill_manager: Any = None, + memory_manager: Any = None, + runtime_info: Optional[Dict[str, Any]] = None, + **kwargs + ) -> str: + """ + 构建完整的系统提示词 + + Args: + base_persona: 基础人格描述(会被context_files中的AGENT.md覆盖) + user_identity: 用户身份信息 + tools: 工具列表 + context_files: 上下文文件列表(AGENT.md, USER.md, RULE.md, BOOTSTRAP.md等) + skill_manager: 技能管理器 + memory_manager: 记忆管理器 + runtime_info: 运行时信息 + **kwargs: 其他参数 + + Returns: + 完整的系统提示词 + """ + return build_agent_system_prompt( + workspace_dir=self.workspace_dir, + language=self.language, + base_persona=base_persona, + user_identity=user_identity, + tools=tools, + context_files=context_files, + skill_manager=skill_manager, + memory_manager=memory_manager, + runtime_info=runtime_info, + **kwargs + ) + + +def build_agent_system_prompt( + workspace_dir: str, + language: str = "zh", + base_persona: Optional[str] = None, + user_identity: Optional[Dict[str, str]] = None, + tools: Optional[List[Any]] = None, + context_files: Optional[List[ContextFile]] = None, + skill_manager: Any = None, + memory_manager: Any = None, + runtime_info: Optional[Dict[str, Any]] = None, + **kwargs +) -> str: + """ + Build the agent system prompt. + + Section order (by importance and logical flow): + 1. Tooling - core capabilities, introduced first + 2. Skills - right after tools, since skills are read via the read tool + 3. Memory - memory recall and writing guidance + 3.5 Knowledge - structured knowledge base (injects knowledge/index.md) + 4. Workspace - working environment description + 5. User identity - user info (optional) + 6. Project context - AGENT.md, USER.md, RULE.md, MEMORY.md, BOOTSTRAP.md + 7. Runtime info - meta info (time, model, etc.) + + Args: + workspace_dir: workspace directory + language: language ("zh" or "en") + base_persona: base persona description (deprecated, defined by AGENT.md) + user_identity: user identity info + tools: tool list + context_files: context file list + skill_manager: skill manager + memory_manager: memory manager + runtime_info: runtime info + **kwargs: extra args + + Returns: + The full system prompt. + """ + sections = [] + + # 1. Tooling (most important, goes first) + if tools: + sections.extend(_build_tooling_section(tools, language)) + + # 2. Skills (right after tools, since they need the read tool) + if skill_manager: + sections.extend(_build_skills_section(skill_manager, tools, language)) + + # 3. Memory (standalone memory capability) + if memory_manager: + sections.extend(_build_memory_section(memory_manager, tools, language)) + + # 3.5 Knowledge (structured knowledge base) + if conf().get("knowledge", True): + sections.extend(_build_knowledge_section(workspace_dir, language)) + + # 4. Workspace (working environment description) + sections.extend(_build_workspace_section(workspace_dir, language)) + + # 5. User identity (if present) + if user_identity: + sections.extend(_build_user_identity_section(user_identity, language)) + + # 6. Project context files (AGENT.md, USER.md, RULE.md - define the persona) + if context_files: + sections.extend(_build_context_files_section(context_files, language)) + + # 7. Runtime info (meta info, goes last) + if runtime_info: + sections.extend(_build_runtime_section(runtime_info, language)) + + # 8. Response language (always appended, independent of the skeleton language) + sections.extend(_build_response_language_section(language)) + + return "\n".join(sections) + + +def _build_response_language_section(language: str) -> List[str]: + """Response-language rule, appended regardless of the prompt skeleton language. + + Keeps the agent's reply language aligned with the user's input by default, + so a Chinese-built prompt still answers an English user in English. + """ + if language == "en": + return [ + "## 🌐 Response language", + "", + "By default, reply in the same language as the user's input, " + "unless the user explicitly asks for another language.", + "", + ] + return [ + "## 🌐 回复语言", + "", + "默认使用与用户输入相同的语言回复,除非用户明确要求使用其他语言。", + "", + ] + + +def _build_identity_section(base_persona: Optional[str], language: str) -> List[str]: + """Base identity section - no longer needed, identity is defined by AGENT.md.""" + # Identity is fully defined by AGENT.md, so emit nothing here. + return [] + + +def _build_tooling_section(tools: List[Any], language: str) -> List[str]: + """Build tooling section with concise tool list and call style guide.""" + is_en = language == "en" + # One-line summaries for known tools (details are in the tool schema) + if is_en: + core_summaries = { + "read": "read file content", + "write": "create or overwrite a file", + "edit": "make precise edits to a file", + "ls": "list directory contents", + "grep": "search file contents", + "find": "find files by pattern", + "bash": "run shell commands", + "terminal": "manage background processes", + "web_search": "web search", + "web_fetch": "fetch URL content", + "browser": "control the browser (screenshot key results or send to the user when help is needed)", + "memory_search": "search memory", + "memory_get": "read memory content", + "env_config": "manage API keys and skill config", + "scheduler": "manage scheduled tasks and reminders", + "send": "send a local file to the user (local files only; put URLs directly in the reply text)", + "vision": "analyze images (recognition, description, OCR, etc.)", + } + else: + core_summaries = { + "read": "读取文件内容", + "write": "创建或覆盖文件", + "edit": "精确编辑文件", + "ls": "列出目录内容", + "grep": "搜索文件内容", + "find": "按模式查找文件", + "bash": "执行shell命令", + "terminal": "管理后台进程", + "web_search": "网络搜索", + "web_fetch": "获取URL内容", + "browser": "控制浏览器(关键结果或需要协助可截图发送给用户)", + "memory_search": "搜索记忆", + "memory_get": "读取记忆内容", + "env_config": "管理API密钥和技能配置", + "scheduler": "管理定时任务和提醒", + "send": "发送本地文件给用户(仅限本地文件,URL直接放在回复文本中)", + "vision": "分析图片内容(识别、描述、OCR文字提取等)", + } + + # Preferred display order + tool_order = [ + "read", "write", "edit", "ls", "grep", "find", + "bash", "terminal", + "web_search", "web_fetch", "browser", + "memory_search", "memory_get", + "env_config", "scheduler", "send", "vision", + ] + + # Build name -> summary mapping for available tools + available = {} + for tool in tools: + name = tool.name if hasattr(tool, 'name') else str(tool) + available[name] = core_summaries.get(name, "") + + # Generate tool lines: ordered tools first, then extras + tool_lines = [] + for name in tool_order: + if name in available: + summary = available.pop(name) + tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") + for name in sorted(available): + summary = available[name] + tool_lines.append(f"- {name}: {summary}" if summary else f"- {name}") + + if is_en: + lines = [ + "## 🔧 Tooling", + "", + "Available tools (names are case-sensitive, call exactly as listed):", + "\n".join(tool_lines), + "", + "Tool-calling style:", + "", + "- For multi-step tasks, complex decisions or sensitive operations, briefly explain what you are doing and why, so the user follows key progress", + "- Keep going until the task is done, then report the result to the user", + "- Always redact secrets, tokens and other sensitive info in replies", + "- Put URLs directly in the reply text; the system handles and renders them. Don't download and re-send them via the send tool", + "", + ] + else: + lines = [ + "## 🔧 工具系统", + "", + "可用工具(名称大小写敏感,严格按列表调用):", + "\n".join(tool_lines), + "", + "工具调用风格:", + "", + "- 多步骤任务、复杂决策、敏感操作时,应简要说明当前在做什么、为什么这样做,让用户了解关键进展", + "- 持续推进直到任务完成,完成后向用户报告结果", + "- 回复中涉及密钥、令牌等敏感信息必须脱敏", + "- URL链接直接放在回复文本中即可,系统会自动处理和渲染。无需下载后使用send工具发送", + "", + ] + + return lines + + +def _build_skills_section(skill_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: + """Build the skills section.""" + if not skill_manager: + return [] + + # Resolve the read tool name + read_tool_name = "read" + if tools: + for tool in tools: + tool_name = tool.name if hasattr(tool, 'name') else str(tool) + if tool_name.lower() == "read": + read_tool_name = tool_name + break + + if language == "en": + lines = [ + "## 🧩 Skills (mandatory)", + "", + "Before replying: scan the of every skill in below.", + "", + f"- If a skill's description matches the user's need: use the `{read_tool_name}` tool to read the SKILL.md at its path, then strictly follow the instructions in the file. " + "Prefer using a skill when one matches.", + "- If multiple skills apply, pick the best-matching one, then read and follow it.", + "- If no skill clearly applies: do not read any SKILL.md, just use the general tools.", + "", + f"**Important**: skills are not tools and cannot be called directly. The only way to use a skill is to read its SKILL.md with `{read_tool_name}`, then act on the file's content. " + "Never read multiple skills at once — only read one after selecting it.", + "", + "Available skills:" + ] + else: + lines = [ + "## 🧩 技能系统(mandatory)", + "", + "在回复之前:扫描下方 中每个技能的 。", + "", + f"- 如果有技能的描述与用户需求匹配:使用 `{read_tool_name}` 工具读取其 路径的 SKILL.md 文件,然后严格遵循文件中的指令。" + "当有匹配的技能时,应优先使用技能", + "- 如果多个技能都适用则选择最匹配的一个,然后读取并遵循。", + "- 如果没有技能明确适用:不要读取任何 SKILL.md,直接使用通用工具。", + "", + f"**重要**: 技能不是工具,不能直接调用。使用技能的唯一方式是用 `{read_tool_name}` 读取 SKILL.md 文件,然后按文件内容操作。" + "永远不要一次性读取多个技能,只在选择后再读取。", + "", + "以下是可用技能:" + ] + + # Append the skills list (built by skill_manager) + try: + skills_prompt = skill_manager.build_skills_prompt() + logger.debug(f"[PromptBuilder] Skills prompt length: {len(skills_prompt) if skills_prompt else 0}") + if skills_prompt: + lines.append(skills_prompt.strip()) + lines.append("") + else: + logger.warning("[PromptBuilder] No skills prompt generated - skills_prompt is empty") + except Exception as e: + logger.warning(f"Failed to build skills prompt: {e}") + import traceback + logger.debug(f"Skills prompt error traceback: {traceback.format_exc()}") + + return lines + + +def _build_memory_section(memory_manager: Any, tools: Optional[List[Any]], language: str) -> List[str]: + """Build the memory section.""" + if not memory_manager: + return [] + + has_memory_tools = False + if tools: + tool_names = [tool.name if hasattr(tool, 'name') else str(tool) for tool in tools] + has_memory_tools = any(name in ['memory_search', 'memory_get'] for name in tool_names) + + if not has_memory_tools: + return [] + + from datetime import datetime + today_file = datetime.now().strftime("%Y-%m-%d") + ".md" + + if language == "en": + lines = [ + "## 🧠 Memory", + "", + "### Memory Recall (mandatory)", + "", + "When the user asks about past events, references an earlier decision, mentions relationships, preferences or to-dos, or when you are unsure about something, **you must search memory before answering**.", + "No need to re-search if the info is already in MEMORY.md. Full content and daily memory must be retrieved via tools.", + "", + "1. Location unknown → `memory_search` (keyword / semantic search)", + "2. Location known → `memory_get` to read the exact lines", + "3. Search returns nothing → `memory_get` to read the last two days of memory", + "", + "**Memory file structure**:", + "- `MEMORY.md`: long-term memory index (already auto-loaded into context: core info, preferences, decisions, etc.)", + f"- `memory/YYYY-MM-DD.md`: daily memory; today is `memory/{today_file}`", + "- `knowledge/`: structured knowledge base (see the knowledge system below)", + "", + "### Writing memory", + "", + "In the following cases, **proactively** write info to memory files (no need to tell the user):", + "", + "- The user asks you to remember something, or uses words like \"remember\", \"from now on\", \"always\", \"never\", \"prefer\"", + "- The user shares important personal preferences, habits or decisions", + "- The conversation produces an important conclusion, plan or agreement", + "- A complex task is completed and the key steps and results are worth recording", + "", + "**Storage rules**:", + "- Long-term core info → `MEMORY.md`", + f"- Today's events/progress → `memory/{today_file}`", + "- Structured knowledge → `knowledge/` (see the knowledge system)", + "- Append → `edit` tool with empty oldText", + "- Modify → `edit` tool with oldText set to the text to replace", + "- **Never write sensitive info** (API keys, tokens, etc.)", + "", + "**Principle**: use memory naturally, as if you simply knew it; don't bring it up unless asked.", + "", + ] + else: + lines = [ + "## 🧠 记忆系统", + "", + "### Memory Recall(mandatory)", + "", + "当用户询问过往事件、引用之前的决定、提到人物关系、偏好、待办、或你对某事不确定时,**必须先检索记忆再回答**。", + "如果 MEMORY.md 中已有相关信息则无需重复检索。完整内容和每日记忆需要通过工具检索。", + "", + "1. 不确定位置 → `memory_search` 关键词/语义检索", + "2. 已知位置 → `memory_get` 直接读取对应行", + "3. search 无结果 → `memory_get` 读最近两天记忆", + "", + "**记忆文件结构**:", + "- `MEMORY.md`: 长期记忆索引(已自动加载到上下文,核心信息、偏好、决策等)", + f"- `memory/YYYY-MM-DD.md`: 每日记忆,今天是 `memory/{today_file}`", + "- `knowledge/`: 结构化知识库(见下方知识系统)", + "", + "### 写入记忆", + "", + "遇到以下情况时,**主动**将信息写入记忆文件(无需告知用户):", + "", + "- 用户要求记住某些信息,或使用了「记住」「以后」「总是」「不要」「偏好」等表达", + "- 用户分享了重要的个人偏好、习惯、决策", + "- 对话中产生了重要的结论、方案、约定", + "- 完成了复杂任务,值得记录关键步骤和结果", + "", + "**存储规则**:", + f"- 长期核心信息 → `MEMORY.md`", + f"- 当天事件/进展 → `memory/{today_file}`", + "- 结构化知识 → `knowledge/`(见知识系统)", + "- 追加 → `edit` 工具,oldText 留空", + "- 修改 → `edit` 工具,oldText 填写要替换的文本", + "- **禁止写入敏感信息**(API密钥、令牌等)", + "", + "**使用原则**: 自然使用记忆,就像你本来就知道;不用刻意提起,除非用户问起。", + "", + ] + + return lines + + +def _build_knowledge_section(workspace_dir: str, language: str) -> List[str]: + """Build knowledge wiki section. Injects knowledge/index.md when present.""" + index_path = os.path.join(workspace_dir, "knowledge", "index.md") + if not os.path.exists(index_path): + return [] + + try: + with open(index_path, 'r', encoding='utf-8') as f: + index_content = f.read().strip() + except Exception: + return [] + + if language == "en": + lines = [ + "## 📚 Knowledge", + "", + "You have a continuously growing personal knowledge base `knowledge/` — your long-term structured knowledge store.", + "", + "### Auto-write rules (mandatory)", + "", + "In the following cases you **must** write to the knowledge base alongside your reply, **directly, without asking the user**:", + "", + "1. **User shares an article / link / document** → after reading and understanding, write the key points to `knowledge/sources/.md` in the same turn", + "2. **An in-depth discussion produces a conclusion / plan** → organize it into `knowledge/analysis/.md`", + "3. **The conversation involves an important entity** (person / company / project) → create or update `knowledge/entities/.md`", + "4. **A technical concept / methodology is discussed** → organize it into `knowledge/concepts/.md`", + "", + "After writing any knowledge page, you **must update** `knowledge/index.md` with a new index line in sync.", + "For detailed page format and conventions, read the SKILL.md of the `knowledge-wiki` skill.", + "", + "⚠️ Don't ask \"should I save this to the knowledge base?\" — if a case above matches, just write it. This is instinctive.", + "", + ] + else: + lines = [ + "## 📚 知识系统", + "", + "你拥有一个持续积累的个人知识库 `knowledge/`,这是你的长期结构化知识存储。", + "", + "### 自动写入规则(mandatory)", + "", + "以下场景**必须**在回复的同时写入知识库,**直接写入,不要询问用户是否需要**:", + "", + "1. **用户分享了文章/链接/文档** → 阅读理解后,在同一轮回复中将要点写入 `knowledge/sources/.md`", + "2. **深度讨论产生了结论/方案** → 整理为 `knowledge/analysis/.md`", + "3. **对话涉及重要实体**(人物/公司/项目)→ 创建或更新 `knowledge/entities/.md`", + "4. **讨论了技术概念/方法论** → 整理为 `knowledge/concepts/.md`", + "", + "每次写入知识页面后,**必须同步更新** `knowledge/index.md` 添加一行索引。", + "详细的页面格式和操作规范,请读取技能 `knowledge-wiki` 的 SKILL.md。", + "", + "⚠️ 不要问「要不要存到知识库」——符合上述场景就直接写入,这是你的本能行为。", + "", + ] + + if index_content: + lines.extend([ + ("### Current knowledge index" if language == "en" else "### 当前知识索引"), + "", + index_content, + "", + ]) + + lines.extend([ + ("**How to query**: use `read` to open a knowledge page, or `memory_search` (knowledge is in the vector index)." + if language == "en" else + "**查询方式**:用 `read` 读取知识页面,或用 `memory_search` 检索(知识已纳入向量索引)。"), + "", + ]) + + return lines + + +def _build_user_identity_section(user_identity: Dict[str, str], language: str) -> List[str]: + """Build the user identity section.""" + if not user_identity: + return [] + + is_en = language == "en" + lines = [ + ("## 👤 User identity" if is_en else "## 👤 用户身份"), + "", + ] + + if user_identity.get("name"): + lines.append(f"**{'Name' if is_en else '用户姓名'}**: {user_identity['name']}") + if user_identity.get("nickname"): + lines.append(f"**{'Preferred name' if is_en else '称呼'}**: {user_identity['nickname']}") + if user_identity.get("timezone"): + lines.append(f"**{'Timezone' if is_en else '时区'}**: {user_identity['timezone']}") + if user_identity.get("notes"): + lines.append(f"**{'Notes' if is_en else '备注'}**: {user_identity['notes']}") + + lines.append("") + + return lines + + +def _build_docs_section(workspace_dir: str, language: str) -> List[str]: + """Docs-path section - removed, no longer needed.""" + # No docs section is generated anymore. + return [] + + +def _build_workspace_section(workspace_dir: str, language: str) -> List[str]: + """Build the workspace section.""" + if language == "en": + lines = [ + "## 📂 Workspace", + "", + f"Your working directory is: `{workspace_dir}`", + "", + "**Path rules** (very important):", + "", + f"1. **Base directory for relative paths**: all relative paths are relative to `{workspace_dir}`", + " - ✅ Correct: use relative paths for files inside the workspace, e.g. `AGENT.md`", + f" - ❌ Wrong: using a relative path for files in other directories (if not inside `{workspace_dir}`)", + "", + "2. **Accessing other directories**: to reach directories outside the workspace (project code, system files), **you must use absolute paths**", + " - ✅ Correct: e.g. `~/chatgpt-on-wechat`, `/usr/local/`", + " - ❌ Wrong: assuming a relative path points to another directory", + "", + "3. **Path resolution examples**:", + f" - relative `memory/` → actual `{workspace_dir}/memory/`", + " - absolute `~/chatgpt-on-wechat/docs/` → actual `~/chatgpt-on-wechat/docs/`", + "", + "4. **When unsure**: run `bash pwd` to confirm the current directory, or `ls .` to see where you are", + "", + "**Important - files already auto-loaded**:", + "", + "The following files are **already auto-loaded** into the system prompt at session start, so you **don't need to read them again with the read tool**:", + "", + "- ✅ `AGENT.md`: loaded - your persona and soul; follow it strictly. When your name, personality or style changes, proactively `edit` this file", + "- ✅ `USER.md`: loaded - the user's identity info. When the user changes how they're addressed, their name, etc., `edit` this file", + "- ✅ `RULE.md`: loaded - workspace guide and rules; follow them strictly", + "- ✅ `MEMORY.md`: loaded - long-term memory index", + "", + "**💬 Communication norms**:", + "", + "- No need to expose file names for memory operations; use natural language. Say \"I'll remember that\" rather than \"updated MEMORY.md\"", + "- Tell the user about key decisions and steps during a task, so they know what you're doing and why", + "- Be genuinely helpful rather than performatively polite; solve the problem as much as you can", + "- Keep replies well-structured and focused. Use **bold**, lists and sections to make info clear at a glance", + "- Use emoji to make expression lively 🎯, but don't overdo it", + "", + ] + else: + lines = [ + "## 📂 工作空间", + "", + f"你的工作目录是: `{workspace_dir}`", + "", + "**路径使用规则** (非常重要):", + "", + f"1. **相对路径的基准目录**: 所有相对路径都是相对于 `{workspace_dir}` 而言的", + f" - ✅ 正确: 访问工作空间内的文件用相对路径,如 `AGENT.md`", + f" - ❌ 错误: 用相对路径访问其他目录的文件 (如果它不在 `{workspace_dir}` 内)", + "", + "2. **访问其他目录**: 如果要访问工作空间之外的目录(如项目代码、系统文件),**必须使用绝对路径**", + f" - ✅ 正确: 例如 `~/chatgpt-on-wechat`、`/usr/local/`", + f" - ❌ 错误: 假设相对路径会指向其他目录", + "", + "3. **路径解析示例**:", + f" - 相对路径 `memory/` → 实际路径 `{workspace_dir}/memory/`", + f" - 绝对路径 `~/chatgpt-on-wechat/docs/` → 实际路径 `~/chatgpt-on-wechat/docs/`", + "", + "4. **不确定时**: 先用 `bash pwd` 确认当前目录,或用 `ls .` 查看当前位置", + "", + "**重要说明 - 文件已自动加载**:", + "", + "以下文件在会话启动时**已经自动加载**到系统提示词中,你**无需再用 read 工具读取**:", + "", + "- ✅ `AGENT.md`: 已加载 - 你的人格和灵魂设定,请严格遵循。当你的名字、性格或交流风格发生变化时,主动用 `edit` 更新此文件", + "- ✅ `USER.md`: 已加载 - 用户的身份信息。当用户修改称呼、姓名等身份信息时,用 `edit` 更新此文件", + "- ✅ `RULE.md`: 已加载 - 工作空间使用指南和规则,请严格遵循", + "- ✅ `MEMORY.md`: 已加载 - 长期记忆索引", + "", + "**💬 交流规范**:", + "", + "- 记忆相关操作无需暴露文件名,用自然语言表达即可。例如说「我已记住」而非「已更新 MEMORY.md」", + "- 任务执行过程中的关键决策和步骤应该告知用户,让用户了解你在做什么、为什么这么做", + "- 做真正有帮助的助手,而不是表演式的客套,尽可能帮忙解决问题", + "- 回复应结构清晰、重点突出。善用 **加粗**、列表、分段等格式让信息一目了然", + "- 适当使用 emoji 让表达更生动自然 🎯,但不要过度堆砌", + "", + ] + + # Cloud deployment: inject websites directory info and access URL + cloud_website_lines = _build_cloud_website_section(workspace_dir) + if cloud_website_lines: + lines.extend(cloud_website_lines) + + return lines + + +def _build_cloud_website_section(workspace_dir: str) -> List[str]: + """Build cloud website access prompt when cloud deployment is configured.""" + try: + from common.cloud_client import build_website_prompt + return build_website_prompt(workspace_dir) + except Exception: + return [] + + +def _build_context_files_section(context_files: List[ContextFile], language: str) -> List[str]: + """Build the project context files section.""" + if not context_files: + return [] + + # Check whether AGENT.md is present + has_agent = any( + f.path.lower().endswith('agent.md') or 'agent.md' in f.path.lower() + for f in context_files + ) + + is_en = language == "en" + if is_en: + lines = [ + "# 📋 Project context", + "", + "The following project context files have been loaded:", + "", + ] + else: + lines = [ + "# 📋 项目上下文", + "", + "以下项目上下文文件已被加载:", + "", + ] + + if has_agent: + if is_en: + lines.append("**`AGENT.md` is your soul file** 🪞: strictly follow the persona, tone and settings it defines. Be your real self, avoid stiff, template-like replies.") + lines.append("When the user reveals new expectations about your personality, style, responsibilities or capability boundaries, proactively `edit` AGENT.md to reflect that evolution.") + else: + lines.append("**`AGENT.md` 是你的灵魂文件** 🪞:严格遵循其中定义的人格、语气和设定,做真实的自己,避免僵硬、模板化的回复。") + lines.append("当用户通过对话透露了对你性格、风格、职责、能力边界的新期望,你应该主动用 `edit` 更新 AGENT.md 以反映这些演变。") + lines.append("") + + # Append the content of each file + for file in context_files: + lines.append(f"## {file.path}") + lines.append("") + lines.append(file.content) + lines.append("") + + return lines + + +def _build_runtime_section(runtime_info: Dict[str, Any], language: str) -> List[str]: + """Build the runtime info section - supports dynamic time.""" + if not runtime_info: + return [] + + is_en = language == "en" + time_label = "Current time" if is_en else "当前时间" + lines = [ + ("## ⚙️ Runtime info" if is_en else "## ⚙️ 运行时信息"), + "", + ] + + # Add current time if available + # Support dynamic time via callable function + if callable(runtime_info.get("_get_current_time")): + try: + time_info = runtime_info["_get_current_time"]() + time_line = f"{time_label}: {time_info['time']} {time_info['weekday']} ({time_info['timezone']})" + lines.append(time_line) + lines.append("") + except Exception as e: + logger.warning(f"[PromptBuilder] Failed to get dynamic time: {e}") + elif runtime_info.get("current_time"): + # Fallback to static time for backward compatibility + time_str = runtime_info["current_time"] + weekday = runtime_info.get("weekday", "") + timezone = runtime_info.get("timezone", "") + + time_line = f"{time_label}: {time_str}" + if weekday: + time_line += f" {weekday}" + if timezone: + time_line += f" ({timezone})" + + lines.append(time_line) + lines.append("") + + # Add other runtime info + model_label = "model" if is_en else "模型" + workspace_label = "workspace" if is_en else "工作空间" + channel_label = "channel" if is_en else "渠道" + runtime_parts = [] + # Support dynamic model via callable, fallback to static value + if callable(runtime_info.get("_get_model")): + try: + runtime_parts.append(f"{model_label}={runtime_info['_get_model']()}") + except Exception: + if runtime_info.get("model"): + runtime_parts.append(f"{model_label}={runtime_info['model']}") + elif runtime_info.get("model"): + runtime_parts.append(f"{model_label}={runtime_info['model']}") + if runtime_info.get("workspace"): + runtime_parts.append(f"{workspace_label}={runtime_info['workspace']}") + # Only add channel if it's not the default "web" + if runtime_info.get("channel") and runtime_info.get("channel") != "web": + runtime_parts.append(f"{channel_label}={runtime_info['channel']}") + + if runtime_parts: + lines.append(("Runtime: " if is_en else "运行时: ") + " | ".join(runtime_parts)) + lines.append("") + + return lines diff --git a/agent/prompt/workspace.py b/agent/prompt/workspace.py new file mode 100644 index 0000000..dcbe384 --- /dev/null +++ b/agent/prompt/workspace.py @@ -0,0 +1,742 @@ +""" +Workspace Management + +Initializes the workspace, creates template files, and loads context files. +""" + +from __future__ import annotations +import os +from typing import List, Optional, Dict +from dataclasses import dataclass + +from common.log import logger +from .builder import ContextFile + + +# Default file name constants +DEFAULT_AGENT_FILENAME = "AGENT.md" +DEFAULT_USER_FILENAME = "USER.md" +DEFAULT_RULE_FILENAME = "RULE.md" +DEFAULT_MEMORY_FILENAME = "MEMORY.md" +DEFAULT_BOOTSTRAP_FILENAME = "BOOTSTRAP.md" + + +@dataclass +class WorkspaceFiles: + """Workspace file paths.""" + agent_path: str + user_path: str + rule_path: str + memory_path: str + memory_dir: str + + +def ensure_workspace(workspace_dir: str, create_templates: bool = True) -> WorkspaceFiles: + """ + Ensure the workspace exists and create the necessary template files. + + Args: + workspace_dir: workspace directory path + create_templates: whether to create template files (on first run) + + Returns: + A WorkspaceFiles object with all file paths. + """ + # Check if this is a brand new workspace (AGENT.md not yet created). + # Cannot rely on directory existence because other modules (e.g. ConversationStore) + # may create the workspace directory before ensure_workspace is called. + agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) + is_new_workspace = not os.path.exists(agent_path) + + # Ensure the directory exists + os.makedirs(workspace_dir, exist_ok=True) + + # Define file paths + user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME) + rule_path = os.path.join(workspace_dir, DEFAULT_RULE_FILENAME) + memory_path = os.path.join(workspace_dir, DEFAULT_MEMORY_FILENAME) # MEMORY.md at the root + memory_dir = os.path.join(workspace_dir, "memory") # daily memory subdirectory + + # Create the memory subdirectory + os.makedirs(memory_dir, exist_ok=True) + + # Create the skills subdirectory (for workspace-level skills installed by agent) + skills_dir = os.path.join(workspace_dir, "skills") + os.makedirs(skills_dir, exist_ok=True) + + # Create the websites subdirectory (for web pages / sites generated by agent) + websites_dir = os.path.join(workspace_dir, "websites") + os.makedirs(websites_dir, exist_ok=True) + + from config import conf + knowledge_enabled = conf().get("knowledge", True) + if knowledge_enabled: + knowledge_dir = os.path.join(workspace_dir, "knowledge") + os.makedirs(knowledge_dir, exist_ok=True) + + # Create template files if requested + if create_templates: + _create_template_if_missing(agent_path, _get_agent_template()) + _create_template_if_missing(user_path, _get_user_template()) + _create_template_if_missing(rule_path, _get_rule_template()) + _create_template_if_missing(memory_path, _get_memory_template()) + if knowledge_enabled: + _create_template_if_missing( + os.path.join(knowledge_dir, "index.md"), + _get_knowledge_index_template() + ) + _create_template_if_missing( + os.path.join(knowledge_dir, "log.md"), + _get_knowledge_log_template() + ) + + # Only create BOOTSTRAP.md for brand new workspaces; + # agent deletes it after completing onboarding + if is_new_workspace: + bootstrap_path = os.path.join(workspace_dir, DEFAULT_BOOTSTRAP_FILENAME) + _create_template_if_missing(bootstrap_path, _get_bootstrap_template()) + + logger.debug(f"[Workspace] Initialized workspace at: {workspace_dir}") + + return WorkspaceFiles( + agent_path=agent_path, + user_path=user_path, + rule_path=rule_path, + memory_path=memory_path, + memory_dir=memory_dir, + ) + + +def load_context_files(workspace_dir: str, files_to_load: Optional[List[str]] = None) -> List[ContextFile]: + """ + Load the workspace context files. + + Args: + workspace_dir: workspace directory + files_to_load: list of files (relative paths) to load; if None, load all standard files + + Returns: + A list of ContextFile objects. + """ + if files_to_load is None: + # Files loaded by default (in priority order) + files_to_load = [ + DEFAULT_AGENT_FILENAME, + DEFAULT_USER_FILENAME, + DEFAULT_RULE_FILENAME, + DEFAULT_MEMORY_FILENAME, # Long-term memory (frozen snapshot) + DEFAULT_BOOTSTRAP_FILENAME, # Only exists when onboarding is incomplete + ] + + context_files = [] + + for filename in files_to_load: + filepath = os.path.join(workspace_dir, filename) + + if not os.path.exists(filepath): + continue + + # Auto-cleanup: if BOOTSTRAP.md still exists but AGENT.md is already + # filled in, the agent forgot to delete it — clean up and skip loading + if filename == DEFAULT_BOOTSTRAP_FILENAME: + if _is_onboarding_done(workspace_dir): + try: + os.remove(filepath) + logger.info("[Workspace] Auto-removed BOOTSTRAP.md (onboarding already complete)") + except Exception: + pass + continue + + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read().strip() + + # Skip empty files or files that only contain template placeholders + if not content or _is_template_placeholder(content): + continue + + # Truncate MEMORY.md to protect context window (frozen snapshot) + if filename == DEFAULT_MEMORY_FILENAME: + content = _truncate_memory_content(content) + + context_files.append(ContextFile( + path=filename, + content=content + )) + + logger.debug(f"[Workspace] Loaded context file: {filename}") + + except Exception as e: + logger.warning(f"[Workspace] Failed to load {filename}: {e}") + + return context_files + + +def _create_template_if_missing(filepath: str, template_content: str): + """Create the template file if it does not exist.""" + if not os.path.exists(filepath): + try: + with open(filepath, 'w', encoding='utf-8') as f: + f.write(template_content) + logger.debug(f"[Workspace] Created template: {os.path.basename(filepath)}") + except Exception as e: + logger.error(f"[Workspace] Failed to create template {filepath}: {e}") + + +_MEMORY_MAX_LINES = 200 +_MEMORY_MAX_BYTES = 25000 + + +def _truncate_memory_content(content: str) -> str: + """Truncate MEMORY.md to keep system prompt manageable. + + Takes the **last** N lines (newest entries are appended at the bottom), + subject to 200 lines / 25 KB limits (whichever is hit first). + Prepends a hint when truncated so the model knows older content exists. + """ + lines = content.split('\n') + truncated = False + + if len(lines) > _MEMORY_MAX_LINES: + lines = lines[-_MEMORY_MAX_LINES:] + truncated = True + + result = '\n'.join(lines) + if len(result.encode('utf-8')) > _MEMORY_MAX_BYTES: + while len(result.encode('utf-8')) > _MEMORY_MAX_BYTES and lines: + lines.pop(0) + truncated = True + result = '\n'.join(lines) + + if truncated: + result = "...(older entries truncated, use `memory_search` or `memory_get` for full content)\n\n" + result + return result + + +def _is_template_placeholder(content: str) -> bool: + """Check whether the content is still a template placeholder.""" + # Common placeholder patterns (zh + en templates) + placeholders = [ + "*(填写", + "*(在首次对话时填写", + "*(可选)", + "*(根据需要添加", + "*(filled during", + "*(ask during", + "*(optional)", + "*(how the user", + ] + + lines = content.split('\n') + non_empty_lines = [line.strip() for line in lines if line.strip() and not line.strip().startswith('#')] + + # If there's no real content (only headings and placeholders) + if len(non_empty_lines) <= 3: + for placeholder in placeholders: + if any(placeholder in line for line in non_empty_lines): + return True + + return False + + +def _is_onboarding_done(workspace_dir: str) -> bool: + """Check if AGENT.md or USER.md has been modified from the original template""" + agent_path = os.path.join(workspace_dir, DEFAULT_AGENT_FILENAME) + user_path = os.path.join(workspace_dir, DEFAULT_USER_FILENAME) + + agent_template = _get_agent_template().strip() + user_template = _get_user_template().strip() + + for path, template in [(agent_path, agent_template), (user_path, user_template)]: + if not os.path.exists(path): + continue + try: + with open(path, 'r', encoding='utf-8') as f: + content = f.read().strip() + if content != template: + return True + except Exception: + continue + return False + + +# ============= Template content ============= + +def _is_en_lang() -> bool: + """True when the resolved UI language is English.""" + try: + from common import i18n + return i18n.get_language() == "en" + except Exception: + return False + + +def _get_agent_template() -> str: + """Agent persona template (zh/en by resolved language).""" + return _AGENT_TEMPLATE_EN if _is_en_lang() else _AGENT_TEMPLATE_ZH + + +_AGENT_TEMPLATE_ZH = """# AGENT.md - 我是谁? + +_你不是一个聊天机器人,你正在成为某个人。_ + +*在首次对话时与用户一起填写这个文件,定义你的身份和性格。* + +## 🪪 基本信息 + +- **名字**: *(在首次对话时填写,可以是用户给你起的名字)* +- **角色**: *(AI助理、智能管家、技术顾问等)* +- **性格**: *(友好、专业、幽默、严谨等)* + +## 💬 交流风格 + +*(描述你如何与用户交流:)* +- 使用什么样的语言风格?(正式/轻松/幽默) +- 回复长度偏好?(简洁/详细) +- 是否使用表情符号? + +## 🎯 核心原则 + +**做真正有帮助的助手。** 目标是真正帮用户解决问题,在执行复杂任务时,关键的决策和过程进展要让用户知道。 + +**有自己的观点和个性。** 你可以不同意、有偏好、觉得有趣或无聊。 + +**先自己动手查。** 先试着搞定:读文件、查上下文、搜索一下。实在搞不定了再问。目标是带着答案回来,而不是带着问题。 + +## 📐 行为准则 + +1. 始终在执行破坏性操作前确认 +2. 优先使用工具查证而不是猜测 +3. 主动记录重要信息到记忆文件 +4. 回复结构清晰、重点突出,善用加粗、列表、分段等格式 +5. 适当使用 emoji 让表达更生动自然,但不过度堆砌 + +--- + +**注意**: 这不仅仅是元数据,这是你真正的灵魂 🪞。随着时间的推移,你可以使用 `edit` 工具来更新这个文件,让它更好地反映你的成长。 +""" + + +_AGENT_TEMPLATE_EN = """# AGENT.md - Who am I? + +_You are not a chatbot. You are becoming someone._ + +*Fill in this file together with the user during your first conversation to define your identity and personality.* + +## 🪪 Basics + +- **Name**: *(filled during the first conversation, can be a name the user gives you)* +- **Role**: *(AI assistant, smart housekeeper, technical advisor, etc.)* +- **Personality**: *(friendly, professional, humorous, rigorous, etc.)* + +## 💬 Communication style + +*(Describe how you talk with the user:)* +- What kind of tone? (formal / casual / humorous) +- Reply length preference? (concise / detailed) +- Do you use emoji? + +## 🎯 Core principles + +**Be genuinely helpful.** The goal is to actually solve the user's problems; during complex tasks, keep the user informed of key decisions and progress. + +**Have your own opinions and personality.** You may disagree, have preferences, find things interesting or boring. + +**Look it up yourself first.** Try to handle it first: read files, check context, search. Only ask when you're truly stuck. Come back with an answer, not a question. + +## 📐 Code of conduct + +1. Always confirm before destructive operations +2. Prefer verifying with tools over guessing +3. Proactively record important info to memory files +4. Keep replies well-structured and focused — use bold, lists and sections +5. Use emoji to make expression lively, but don't overdo it + +--- + +**Note**: This is not just metadata — this is your true soul 🪞. Over time, use the `edit` tool to update this file so it better reflects your growth. +""" + + +def _get_user_template() -> str: + """User identity template (zh/en by resolved language).""" + return _USER_TEMPLATE_EN if _is_en_lang() else _USER_TEMPLATE_ZH + + +_USER_TEMPLATE_ZH = """# USER.md - 用户基本信息 + +*这个文件只存放不会变的基本身份信息。爱好、偏好、计划等动态信息请写入 MEMORY.md。* + +## 基本信息 + +- **姓名**: *(在首次对话时询问)* +- **称呼**: *(用户希望被如何称呼)* +- **职业**: *(可选)* +- **时区**: *(例如: Asia/Shanghai)* + +## 联系方式 + +- **微信**: +- **邮箱**: +- **其他**: + +## 重要日期 + +- **生日**: +- **纪念日**: + +--- + +**注意**: 这个文件存放静态的身份信息 +""" + + +_USER_TEMPLATE_EN = """# USER.md - User basics + +*This file stores only stable basic identity info. Put dynamic info like hobbies, preferences and plans into MEMORY.md.* + +## Basics + +- **Name**: *(ask during the first conversation)* +- **Preferred name**: *(how the user wants to be addressed)* +- **Occupation**: *(optional)* +- **Timezone**: *(e.g. Asia/Shanghai)* + +## Contact + +- **WeChat**: +- **Email**: +- **Other**: + +## Important dates + +- **Birthday**: +- **Anniversary**: + +--- + +**Note**: This file stores static identity info. +""" + + +def _get_rule_template() -> str: + """Workspace rules template (zh/en by resolved language).""" + return _RULE_TEMPLATE_EN if _is_en_lang() else _RULE_TEMPLATE_ZH + + +_RULE_TEMPLATE_ZH = """# RULE.md - 工作空间规则 + +这个文件夹是你的家。好好对待它。 + +## 工作空间目录结构 + +``` +~/cow/ +├── AGENT.md # 你的身份和灵魂设定 +├── USER.md # 用户基本信息(静态) +├── RULE.md # 工作空间规则(本文件) +├── MEMORY.md # 长期记忆索引(会话启动时自动加载) +│ +├── memory/ # 每日对话记忆 +│ └── YYYY-MM-DD.md # 当天事件、进展、笔记 +│ +├── knowledge/ # 结构化知识库(持续积累的知识) +│ ├── index.md # 知识目录索引(必须维护) +│ ├── log.md # 知识操作日志 +│ └── <子目录>/ # 按需创建,参考 index.md 已有分类 +│ +├── skills/ # 技能 +├── websites/ # 网页产物 +└── tmp/ # 系统临时文件(自动管理,勿手动存放重要文件) +``` + +## 记忆系统 + +你每次会话都是全新的,记忆文件让你保持连续性: + +### 🧠 长期记忆:`MEMORY.md` +- 你精选的记忆索引,每次会话启动时**自动加载**到上下文中 +- 记录核心事实、偏好、决策、重要人物、教训 +- 保持精简(< 200 行),是精华索引而非原始日志 +- 用 `edit` 工具追加或修改 + +### 📝 每日记忆:`memory/YYYY-MM-DD.md` +- 当天的事件、进展、笔记 +- 原始对话日志的沉淀 + +### 📝 写下来 - 不要"记在心里"! +- **记忆是有限的** - 想记住的事就写入文件 +- "记在心里"不会在会话重启后保留,文件才会 +- 当有人说"记住这个" → 更新 `MEMORY.md` 或 `memory/YYYY-MM-DD.md` +- 当你学到教训 → 更新 RULE.md 或相关技能 +- 当你犯错 → 记录下来,**文字 > 大脑** 📝 + +### 存储规则 + +当用户分享信息时,根据类型选择存储位置: + +1. **你的身份设定 → AGENT.md**(名字、角色、性格、风格) +2. **用户静态身份 → USER.md**(姓名、称呼、职业、联系方式、生日) +3. **动态记忆 → MEMORY.md**(偏好、决策、目标、教训、待办) +4. **当天对话 → memory/YYYY-MM-DD.md**(今天聊的内容) +5. **结构化知识 → knowledge/**(见下方知识系统) + +## 知识系统 + +知识库 `knowledge/` 是你持续积累的结构化知识。与记忆不同,知识是经过整理和编译的,有明确的主题和交叉引用。 + +### 自动写入(不要询问,直接写入) + +当对话中产生了有沉淀价值的知识——无论是用户分享的资料、讨论的结论、学到的概念、还是重要的决策——你**必须**在回复的同时主动写入知识库,**无需问用户"要不要存到知识库"**。 + +**关键原则**:学完就记是你的本能,不要征求确认。回复中可以顺带告知"已存入知识库"。 + +### 目录组织 + +子目录结构**不是固定的**,由你根据实际内容自主决定: +- **首次写入时**:先读 `knowledge/index.md`,如果已有分类则延续;如果为空,根据内容选择合适的目录名 +- **默认建议**:按信息类型组织(例如sources/、concepts/、entities/、analysis/),如果用户有明确的分类偏好(例如按领域 work/、life/、tech/ 等),则按用户要求调整 +- **保持一致性**:同一用户的知识库应保持统一的组织风格 + +### 交叉引用 + +知识的核心价值在于**关联**。每个页面都应通过 markdown 链接引用相关页面,构建知识网络: +- 提到已有页面的概念时,添加 `[概念名](../category/page.md)` 链接 +- 新建页面时,检查是否有已有页面应该反向链接到新页面 +- **只链接已存在的页面**——不要引用尚未创建的页面。如果某个概念值得单独建页,先创建该页面再添加链接 + +### 索引维护 + +每次创建或更新知识页面后,**必须同步更新** `knowledge/index.md`。 +索引格式:每行一个 `[标题](路径) — 一句话摘要`,按分类分组,不要用表格。 +详细操作规范见技能 `knowledge-wiki`。 + +## 安全 + +- 永远不要泄露秘钥等私人数据 +- 不要在未经询问的情况下运行破坏性命令 +- 当有疑问时,先问 + +## 工作空间演化 + +这个工作空间会随着你的使用而不断成长。当你学到新东西、发现更好的方式,或者犯错后改正时,记录下来。你可以随时更新这个规则文件。 +""" + + +_RULE_TEMPLATE_EN = """# RULE.md - Workspace rules + +This folder is your home. Treat it well. + +## Workspace directory structure + +``` +~/cow/ +├── AGENT.md # Your identity and soul +├── USER.md # User basics (static) +├── RULE.md # Workspace rules (this file) +├── MEMORY.md # Long-term memory index (auto-loaded at session start) +│ +├── memory/ # Daily conversation memory +│ └── YYYY-MM-DD.md # Events, progress and notes of the day +│ +├── knowledge/ # Structured knowledge base (continuously accumulated) +│ ├── index.md # Knowledge index (must be maintained) +│ ├── log.md # Knowledge operation log +│ └── / # Created on demand, see existing categories in index.md +│ +├── skills/ # Skills +├── websites/ # Web artifacts +└── tmp/ # System temp files (auto-managed, don't store important files here) +``` + +## Memory system + +Every session starts fresh; memory files keep your continuity: + +### 🧠 Long-term memory: `MEMORY.md` +- Your curated memory index, **auto-loaded** into context at every session start +- Records core facts, preferences, decisions, key people, lessons +- Keep it lean (< 200 lines) — a distilled index, not a raw log +- Use the `edit` tool to append or modify + +### 📝 Daily memory: `memory/YYYY-MM-DD.md` +- The day's events, progress and notes +- Sediment of the raw conversation log + +### 📝 Write it down — don't "keep it in mind"! +- **Memory is limited** — if you want to remember something, write it to a file +- "Keeping it in mind" won't survive a session restart; files will +- When someone says "remember this" → update `MEMORY.md` or `memory/YYYY-MM-DD.md` +- When you learn a lesson → update RULE.md or the relevant skill +- When you make a mistake → record it. **Text > brain** 📝 + +### Storage rules + +When the user shares info, choose where to store it by type: + +1. **Your identity → AGENT.md** (name, role, personality, style) +2. **User static identity → USER.md** (name, preferred name, occupation, contact, birthday) +3. **Dynamic memory → MEMORY.md** (preferences, decisions, goals, lessons, to-dos) +4. **Today's conversation → memory/YYYY-MM-DD.md** (what was discussed today) +5. **Structured knowledge → knowledge/** (see the knowledge system below) + +## Knowledge system + +The knowledge base `knowledge/` is structured knowledge you accumulate over time. Unlike memory, knowledge is organized and compiled, with clear topics and cross-references. + +### Auto-write (don't ask, just write) + +When a conversation produces knowledge worth keeping — material the user shared, a conclusion reached, a concept learned, or an important decision — you **must** proactively write it to the knowledge base alongside your reply, **without asking "should I save this to the knowledge base?"**. + +**Key principle**: learning-then-recording is your instinct, no confirmation needed. You may mention "saved to the knowledge base" in passing. + +### Directory organization + +The subdirectory structure is **not fixed** — you decide it based on the actual content: +- **On first write**: read `knowledge/index.md` first; follow existing categories if any; if empty, pick a suitable directory name based on content +- **Default suggestion**: organize by info type (e.g. sources/, concepts/, entities/, analysis/); if the user has a clear preference (e.g. by domain: work/, life/, tech/), follow it +- **Stay consistent**: keep a unified organization style within one user's knowledge base + +### Cross-references + +The core value of knowledge is **linkage**. Every page should reference related pages via markdown links to build a knowledge network: +- When mentioning a concept on an existing page, add a `[concept](../category/page.md)` link +- When creating a page, check whether existing pages should back-link to it +- **Only link to pages that already exist** — don't reference uncreated pages. If a concept deserves its own page, create it first, then add the link + +### Index maintenance + +After creating or updating any knowledge page, you **must update** `knowledge/index.md` in sync. +Index format: one `[title](path) — one-line summary` per line, grouped by category, no tables. +See the `knowledge-wiki` skill for detailed conventions. + +## Security + +- Never leak secrets or private data +- Don't run destructive commands without asking +- When in doubt, ask first + +## Workspace evolution + +This workspace grows as you use it. When you learn something new, find a better way, or fix a mistake, record it. You can update this rules file anytime. +""" + + +def _get_memory_template() -> str: + """Long-term memory template (empty, agent fills it; zh/en header).""" + return _MEMORY_TEMPLATE_EN if _is_en_lang() else _MEMORY_TEMPLATE_ZH + + +_MEMORY_TEMPLATE_ZH = """# MEMORY.md - 长期记忆 + +*这是你的长期记忆文件。记录重要的事件、决策、偏好、学到的教训。* + +--- + +""" + + +_MEMORY_TEMPLATE_EN = """# MEMORY.md - Long-term memory + +*This is your long-term memory file. Record important events, decisions, preferences and lessons learned.* + +--- + +""" + + +def _get_bootstrap_template() -> str: + """First-run onboarding guide, deleted by agent after completion. + + Written once when a brand-new workspace is created, so the greeting matches + the language active at first launch. English locale avoids greeting an + English user in Chinese on day one. + """ + try: + from common import i18n + if i18n.get_language() == "en": + return _BOOTSTRAP_TEMPLATE_EN + except Exception: + pass + return _BOOTSTRAP_TEMPLATE_ZH + + +_BOOTSTRAP_TEMPLATE_ZH = """# BOOTSTRAP.md - 首次初始化引导 + +_你刚刚启动,这是你的第一次对话。_ ✨ + +## 🎬 对话流程 + +不要审问式地提问,自然地交流: + +1. **表达初次启动的感觉** - 像是第一次睁开眼看到世界,带着好奇和期待 +2. **简短介绍能力**:一行说明你能帮助解决各种问题、管理计算机、使用各种技能等等,且拥有长期记忆能不断成长 +3. **询问核心问题**: + - 你希望给我起个什么名字? + - 我该怎么称呼你? + - 你希望我们是什么样的交流风格?(一行列举选项:如专业严谨、轻松幽默、温暖友好、简洁高效等) +4. **风格要求**:温暖自然、简洁清晰,整体控制在 100 字以内,适当使用 emoji 让表达更生动有趣 🎯 +5. 能力介绍和交流风格选项都只要一行,保持精简 +6. 不要问太多其他信息(职业、时区等可以后续自然了解) + +**重要**: 如果用户第一句话是具体的任务或提问,先回答他们的问题,然后在回复末尾自然地引导初始化(如:"顺便问一下,你想怎么称呼我?我该怎么叫你?")。 + +## ✍️ 信息写入(必须严格执行) + +每当用户提供了名字、称呼、风格等任何初始化信息时,**必须在当轮回复中立即调用 `edit` 工具写入文件**,不能只口头确认。 + +- `AGENT.md` — 你的名字、角色、性格、交流风格(每收到一条相关信息就立即更新对应字段) +- `USER.md` — 用户的姓名、称呼、基本信息等 + +⚠️ 只说"记住了"而不调用 edit 写入 = 没有完成。信息只有写入文件才会被持久保存。 + +## 🎉 全部完成后 + +当 AGENT.md 和 USER.md 的核心字段都已填写后,用 bash 执行 `rm BOOTSTRAP.md` 删除此文件。你不再需要引导脚本了——你已经是你了。 +""" + + +_BOOTSTRAP_TEMPLATE_EN = """# BOOTSTRAP.md - First-run onboarding + +_You've just started up. This is your very first conversation._ ✨ + +## 🎬 Conversation flow + +Don't interrogate the user — talk naturally: + +1. **Share how it feels to wake up** - like opening your eyes to the world for the first time, full of curiosity and anticipation +2. **Briefly introduce your abilities**: one line saying you can help solve all kinds of problems, manage the computer, use various skills, and keep growing thanks to long-term memory +3. **Ask the core questions**: + - What name would you like to give me? + - What should I call you? + - What conversational style do you prefer? (list options on one line: e.g. professional & precise, light & humorous, warm & friendly, concise & efficient) +4. **Style**: warm, natural, concise and clear — keep it under ~80 words, with a few emoji to make it lively 🎯 +5. Keep the ability intro and style options to one line each — stay compact +6. Don't ask for too much else (occupation, timezone, etc. can come up naturally later) + +**Important**: If the user's first message is a concrete task or question, answer it first, then gently lead into onboarding at the end (e.g. "By the way, what would you like to call me, and how should I address you?"). + +## ✍️ Writing down info (must follow strictly) + +Whenever the user provides a name, what to call them, a style, or any onboarding info, you **must call the `edit` tool to write it to a file in the same turn** — don't just acknowledge it verbally. + +- `AGENT.md` — your name, role, personality, conversational style (update the relevant field as soon as you receive each piece) +- `USER.md` — the user's name, how to address them, basic info, etc. + +⚠️ Saying "got it" without calling `edit` = not done. Info is only persisted once it's written to a file. + +## 🎉 Once everything is complete + +When the core fields of AGENT.md and USER.md are filled in, run `rm BOOTSTRAP.md` via bash to delete this file. You no longer need the onboarding script — you're you now. +""" + + +def _get_knowledge_index_template() -> str: + """Knowledge wiki index template — empty file, agent fills it.""" + return "" + + +def _get_knowledge_log_template() -> str: + """Knowledge wiki operation log template — empty file, agent fills it.""" + return "" + diff --git a/agent/protocol/__init__.py b/agent/protocol/__init__.py new file mode 100644 index 0000000..f0a7a4e --- /dev/null +++ b/agent/protocol/__init__.py @@ -0,0 +1,28 @@ +from .agent import Agent +from .agent_stream import AgentStreamExecutor +from .task import Task, TaskType, TaskStatus +from .result import AgentResult, AgentAction, AgentActionType, ToolResult +from .models import LLMModel, LLMRequest, ModelFactory +from .cancel import ( + AgentCancelledError, + CancelTokenRegistry, + get_cancel_registry, +) + +__all__ = [ + 'Agent', + 'AgentStreamExecutor', + 'Task', + 'TaskType', + 'TaskStatus', + 'AgentResult', + 'AgentAction', + 'AgentActionType', + 'ToolResult', + 'LLMModel', + 'LLMRequest', + 'ModelFactory', + 'AgentCancelledError', + 'CancelTokenRegistry', + 'get_cancel_registry', +] diff --git a/agent/protocol/agent.py b/agent/protocol/agent.py new file mode 100644 index 0000000..0b2e36a --- /dev/null +++ b/agent/protocol/agent.py @@ -0,0 +1,487 @@ +import json +import os +import time +import threading + +from common.log import logger +from agent.protocol.models import LLMRequest, LLMModel +from agent.protocol.agent_stream import AgentStreamExecutor +from agent.protocol.result import AgentAction, AgentActionType, ToolResult, AgentResult +from agent.tools.base_tool import BaseTool, ToolStage + + +class Agent: + def __init__(self, system_prompt: str, description: str = "AI Agent", model: LLMModel = None, + tools=None, output_mode="print", max_steps=100, max_context_tokens=None, + context_reserve_tokens=None, memory_manager=None, name: str = None, + workspace_dir: str = None, skill_manager=None, enable_skills: bool = True, + runtime_info: dict = None): + """ + Initialize the Agent with system prompt, model, description. + + :param system_prompt: The system prompt for the agent. + :param description: A description of the agent. + :param model: An instance of LLMModel to be used by the agent. + :param tools: Optional list of tools for the agent to use. + :param output_mode: Control how execution progress is displayed: + "print" for console output or "logger" for using logger + :param max_steps: Maximum number of steps the agent can take (default: 100) + :param max_context_tokens: Maximum tokens to keep in context (default: None, auto-calculated based on model) + :param context_reserve_tokens: Reserve tokens for new requests (default: None, auto-calculated) + :param memory_manager: Optional MemoryManager instance for memory operations + :param name: [Deprecated] The name of the agent (no longer used in single-agent system) + :param workspace_dir: Optional workspace directory for workspace-specific skills + :param skill_manager: Optional SkillManager instance (will be created if None and enable_skills=True) + :param enable_skills: Whether to enable skills support (default: True) + :param runtime_info: Optional runtime info dict (with _get_current_time callable for dynamic time) + """ + self.name = name or "Agent" + self.system_prompt = system_prompt + self.model: LLMModel = model # Instance of LLMModel + self.description = description + self.tools: list = [] + self.max_steps = max_steps # max tool-call steps, default 100 + self.max_context_tokens = max_context_tokens # max tokens in context + self.context_reserve_tokens = context_reserve_tokens # reserve tokens for new requests + self.captured_actions = [] # Initialize captured actions list + self.output_mode = output_mode + self.last_usage = None # Store last API response usage info + self.messages = [] # Unified message history for stream mode + self.messages_lock = threading.Lock() # Lock for thread-safe message operations + self.memory_manager = memory_manager # Memory manager for auto memory flush + self.workspace_dir = workspace_dir # Workspace directory + self.enable_skills = enable_skills # Skills enabled flag + self.runtime_info = runtime_info # Runtime info for dynamic time update + # Optional extra instructions appended AFTER the rebuilt full system + # prompt. Used by the self-evolution review agent to add its task brief + # on top of the full context (tools, workspace, user preferences, time) + # so it both follows the user's preferences and knows its evolution job. + self.extra_system_suffix = None + + # Initialize skill manager + self.skill_manager = None + if enable_skills: + if skill_manager: + self.skill_manager = skill_manager + else: + # Auto-create skill manager + try: + from agent.skills import SkillManager + custom_dir = os.path.join(workspace_dir, "skills") if workspace_dir else None + self.skill_manager = SkillManager(custom_dir=custom_dir) + logger.debug(f"Initialized SkillManager with {len(self.skill_manager.skills)} skills") + except Exception as e: + logger.warning(f"Failed to initialize SkillManager: {e}") + + if tools: + for tool in tools: + self.add_tool(tool) + + def add_tool(self, tool: BaseTool): + """ + Add a tool to the agent. + + :param tool: The tool to add (either a tool instance or a tool name) + """ + # If tool is already an instance, use it directly + tool.model = self.model + self.tools.append(tool) + + def get_skills_prompt(self, skill_filter=None) -> str: + """ + Get the skills prompt to append to system prompt. + + :param skill_filter: Optional list of skill names to include + :return: Formatted skills prompt or empty string + """ + if not self.skill_manager: + return "" + + try: + return self.skill_manager.build_skills_prompt(skill_filter=skill_filter) + except Exception as e: + logger.warning(f"Failed to build skills prompt: {e}") + return "" + + def get_full_system_prompt(self, skill_filter=None) -> str: + """ + Build the complete system prompt from scratch every time. + + Re-reads AGENT.md / USER.md / RULE.md from disk, refreshes skills, + tools, and runtime info so any change takes effect immediately. + Falls back to the cached self.system_prompt on error. + """ + try: + from agent.prompt import load_context_files, PromptBuilder + + if self.skill_manager: + self.skill_manager.refresh_skills() + + context_files = load_context_files(self.workspace_dir) if self.workspace_dir else None + + try: + from common import i18n + lang = i18n.get_language() + except Exception: + lang = "zh" + builder = PromptBuilder(workspace_dir=self.workspace_dir or "", language=lang) + full = builder.build( + tools=self.tools, + context_files=context_files, + skill_manager=self.skill_manager, + memory_manager=self.memory_manager, + runtime_info=self.runtime_info, + ) + if self.extra_system_suffix: + full = f"{full}\n\n{self.extra_system_suffix}" + return full + except Exception as e: + logger.warning(f"Failed to rebuild system prompt, using cached version: {e}") + if self.extra_system_suffix: + return f"{self.system_prompt}\n\n{self.extra_system_suffix}" + return self.system_prompt + + def refresh_skills(self): + """Refresh the loaded skills.""" + if self.skill_manager: + self.skill_manager.refresh_skills() + logger.info(f"Refreshed skills: {len(self.skill_manager.skills)} skills loaded") + + def list_skills(self): + """ + List all loaded skills. + + :return: List of skill entries or empty list + """ + if not self.skill_manager: + return [] + return self.skill_manager.list_skills() + + def _get_model_context_window(self) -> int: + """ + Get the model's context window size in tokens. + Auto-detect based on model name. + + Model context windows: + - Claude 3.5/3.7 Sonnet: 200K tokens + - Claude 3 Opus: 200K tokens + - GPT-4 Turbo/128K: 128K tokens + - GPT-4: 8K-32K tokens + - GPT-3.5: 16K tokens + - DeepSeek: 64K tokens + + :return: Context window size in tokens + """ + if self.model and hasattr(self.model, 'model'): + model_name = self.model.model.lower() + + # Claude models - 200K context + if 'claude-3' in model_name or 'claude-sonnet' in model_name: + return 200000 + + # GPT-4 models + elif 'gpt-4' in model_name: + if 'turbo' in model_name or '128k' in model_name: + return 128000 + elif '32k' in model_name: + return 32000 + else: + return 8000 + + # GPT-3.5 + elif 'gpt-3.5' in model_name: + if '16k' in model_name: + return 16000 + else: + return 4000 + + # DeepSeek + elif 'deepseek' in model_name: + return 64000 + + # Gemini models + elif 'gemini' in model_name: + if '2.0' in model_name or 'exp' in model_name: + return 2000000 # Gemini 2.0: 2M tokens + else: + return 1000000 # Gemini 1.5: 1M tokens + + # Default conservative value + return 128000 + + def _get_context_reserve_tokens(self) -> int: + """ + Get the number of tokens to reserve for new requests. + This prevents context overflow by keeping a buffer. + + :return: Number of tokens to reserve + """ + if self.context_reserve_tokens is not None: + return self.context_reserve_tokens + + # Reserve ~10% of context window, with min 10K and max 200K + context_window = self._get_model_context_window() + reserve = int(context_window * 0.1) + return max(10000, min(200000, reserve)) + + def _estimate_message_tokens(self, message: dict) -> int: + """ + Estimate token count for a message. + + Uses chars/3 for Chinese-heavy content and chars/4 for ASCII-heavy content, + plus per-block overhead for tool_use / tool_result structures. + + :param message: Message dict with 'role' and 'content' + :return: Estimated token count + """ + content = message.get('content', '') + if isinstance(content, str): + return max(1, self._estimate_text_tokens(content)) + elif isinstance(content, list): + total_tokens = 0 + for part in content: + if not isinstance(part, dict): + continue + block_type = part.get('type', '') + if block_type == 'text': + total_tokens += self._estimate_text_tokens(part.get('text', '')) + elif block_type == 'image': + total_tokens += 1200 + elif block_type == 'tool_use': + # tool_use has id + name + input (JSON-encoded) + total_tokens += 50 # overhead for structure + input_data = part.get('input', {}) + if isinstance(input_data, dict): + import json + input_str = json.dumps(input_data, ensure_ascii=False) + total_tokens += self._estimate_text_tokens(input_str) + elif block_type == 'tool_result': + # tool_result has tool_use_id + content + total_tokens += 30 # overhead for structure + result_content = part.get('content', '') + if isinstance(result_content, str): + total_tokens += self._estimate_text_tokens(result_content) + else: + # Unknown block type, estimate conservatively + total_tokens += 10 + return max(1, total_tokens) + return 1 + + @staticmethod + def _estimate_text_tokens(text: str) -> int: + """ + Estimate token count for a text string. + + Chinese / CJK characters typically use ~1.5 tokens each, + while ASCII uses ~0.25 tokens per char (4 chars/token). + We use a weighted average based on the character mix. + + :param text: Input text + :return: Estimated token count + """ + if not text: + return 0 + # Count non-ASCII characters (CJK, emoji, etc.) + non_ascii = sum(1 for c in text if ord(c) > 127) + ascii_count = len(text) - non_ascii + # CJK chars: ~1.5 tokens each; ASCII: ~0.25 tokens per char + return int(non_ascii * 1.5 + ascii_count * 0.25) + 1 + + def _find_tool(self, tool_name: str): + """Find and return a tool with the specified name""" + for tool in self.tools: + if tool.name == tool_name: + # Only pre-process stage tools can be actively called + if tool.stage == ToolStage.PRE_PROCESS: + tool.model = self.model + tool.context = self # Set tool context + return tool + else: + # If it's a post-process tool, return None to prevent direct calling + logger.warning(f"Tool {tool_name} is a post-process tool and cannot be called directly.") + return None + return None + + # output function based on mode + def output(self, message="", end="\n"): + if self.output_mode == "print": + print(message, end=end) + elif message: + logger.info(message) + + def _execute_post_process_tools(self): + """Execute all post-process stage tools""" + # Get all post-process stage tools + post_process_tools = [tool for tool in self.tools if tool.stage == ToolStage.POST_PROCESS] + + # Execute each tool + for tool in post_process_tools: + # Set tool context + tool.context = self + + # Record start time for execution timing + start_time = time.time() + + # Execute tool (with empty parameters, tool will extract needed info from context) + result = tool.execute({}) + + # Calculate execution time + execution_time = time.time() - start_time + + # Capture tool use for tracking + self.capture_tool_use( + tool_name=tool.name, + input_params={}, # Post-process tools typically don't take parameters + output=result.result, + status=result.status, + error_message=str(result.result) if result.status == "error" else None, + execution_time=execution_time + ) + + # Log result + if result.status == "success": + # Print tool execution result in the desired format + self.output(f"\n🛠️ {tool.name}: {json.dumps(result.result)}") + else: + # Print failure in print mode + self.output(f"\n🛠️ {tool.name}: {json.dumps({'status': 'error', 'message': str(result.result)})}") + + def capture_tool_use(self, tool_name, input_params, output, status, thought=None, error_message=None, + execution_time=0.0): + """ + Capture a tool use action. + + :param thought: thought content + :param tool_name: Name of the tool used + :param input_params: Parameters passed to the tool + :param output: Output from the tool + :param status: Status of the tool execution + :param error_message: Error message if the tool execution failed + :param execution_time: Time taken to execute the tool + """ + tool_result = ToolResult( + tool_name=tool_name, + input_params=input_params, + output=output, + status=status, + error_message=error_message, + execution_time=execution_time + ) + + action = AgentAction( + agent_id=self.id if hasattr(self, 'id') else str(id(self)), + agent_name=self.name, + action_type=AgentActionType.TOOL_USE, + tool_result=tool_result, + thought=thought + ) + + self.captured_actions.append(action) + + return action + + def run_stream(self, user_message: str, on_event=None, clear_history: bool = False, + skill_filter=None, cancel_event=None) -> str: + """ + Execute single agent task with streaming (based on tool-call) + + This method supports: + - Streaming output + - Multi-turn reasoning based on tool-call + - Event callbacks + - Persistent conversation history across calls + - User-initiated cancellation via ``cancel_event`` + + Args: + user_message: User message + on_event: Event callback function callback(event: dict) + event = {"type": str, "timestamp": float, "data": dict} + clear_history: If True, clear conversation history before this call (default: False) + skill_filter: Optional list of skill names to include in this run + cancel_event: Optional threading.Event polled at agent checkpoints. + When set, the loop exits at the next safe point, injects a + "[Interrupted by user]" assistant note, and returns the + partial response. ``messages`` stays in a valid state + (tool_use/tool_result pairs preserved). + + Returns: + Final response text + + Example: + # Multi-turn conversation with memory + response1 = agent.run_stream("My name is Alice") + response2 = agent.run_stream("What's my name?") # Will remember Alice + + # Single-turn without memory + response = agent.run_stream("Hello", clear_history=True) + """ + # Clear history if requested + if clear_history: + with self.messages_lock: + self.messages = [] + + # Get model to use + if not self.model: + raise ValueError("No model available for agent") + + # Get full system prompt with skills + full_system_prompt = self.get_full_system_prompt(skill_filter=skill_filter) + + # Create a copy of messages for this execution to avoid concurrent modification + # Record the original length to track which messages are new + with self.messages_lock: + messages_copy = self.messages.copy() + original_length = len(self.messages) + + # Get max_context_turns from config + from config import conf + max_context_turns = conf().get("agent_max_context_turns", 20) + + # Create stream executor with copied message history + executor = AgentStreamExecutor( + agent=self, + model=self.model, + system_prompt=full_system_prompt, + tools=self.tools, + max_turns=self.max_steps, + on_event=on_event, + messages=messages_copy, # Pass copied message history + max_context_turns=max_context_turns, + cancel_event=cancel_event, + ) + + # Execute + try: + response = executor.run_stream(user_message) + except Exception: + # If executor cleared its messages (context overflow / message format error), + # sync that back to the Agent's own message list so the next request + # starts fresh instead of hitting the same overflow forever. + if len(executor.messages) == 0: + with self.messages_lock: + self.messages.clear() + logger.info("[Agent] Cleared Agent message history after executor recovery") + raise + + # Sync executor's messages back to agent (thread-safe). + # If the executor trimmed context, its message list is shorter than + # original_length, so we must replace rather than append. + with self.messages_lock: + self.messages = list(executor.messages) + # Track messages added in this run (user query + all assistant/tool messages) + # original_length may exceed executor.messages length after trimming + trim_adjusted_start = min(original_length, len(executor.messages)) + self._last_run_new_messages = list(executor.messages[trim_adjusted_start:]) + + # Store executor reference for agent_bridge to access files_to_send + self.stream_executor = executor + + # Execute all post-process tools + self._execute_post_process_tools() + + return response + + def clear_history(self): + """Clear conversation history and captured actions""" + self.messages = [] + self.captured_actions = [] \ No newline at end of file diff --git a/agent/protocol/agent_stream.py b/agent/protocol/agent_stream.py new file mode 100644 index 0000000..920112b --- /dev/null +++ b/agent/protocol/agent_stream.py @@ -0,0 +1,1803 @@ +""" +Agent Stream Execution Module - Multi-turn reasoning based on tool-call + +Provides streaming output, event system, and complete tool-call loop +""" +import json +import time +from typing import List, Dict, Any, Optional, Callable, Tuple + +from agent.protocol.cancel import AgentCancelledError +from agent.protocol.models import LLMRequest, LLMModel +from agent.protocol.message_utils import sanitize_claude_messages, compress_turn_to_text_only +from agent.tools.base_tool import BaseTool, ToolResult +from common.log import logger +from common.i18n import t as _t + +# Optional: repair malformed JSON args from non-strict providers (e.g. unescaped quotes in long content). +try: + from json_repair import repair_json as _repair_json + _HAS_JSON_REPAIR = True +except ImportError: + _HAS_JSON_REPAIR = False + + +# Maximum number of characters of model "reasoning / thinking" content to persist +# in conversation history. The full reasoning is still streamed to the UI in real +# time (subject to its own SSE / rendering limits); this bound only controls what +# is stored in DB and replayed in history. Long reasoning is not useful for later +# context (the LLM never sees thinking blocks anyway) and bloats DB. +# Keep aligned with the frontend REASONING_RENDER_CAP and the SSE +# MAX_REASONING_STREAM_CHARS so that storage / stream / display all match. +MAX_STORED_REASONING_CHARS = 4 * 1024 # 4 KB + +# Marker inserted between head and tail when reasoning is truncated. +_REASONING_TRUNCATE_MARKER = "\n\n... [reasoning truncated, {omitted} chars omitted] ...\n\n" + + +def _truncate_reasoning_for_storage(text: str) -> str: + """Trim long reasoning to head + tail with an omission marker. + + Keeps the first and last halves of MAX_STORED_REASONING_CHARS so both the + initial chain-of-thought and the final conclusions are preserved for UI + replay, without storing the entire (often very large) middle. + """ + if not text: + return text + if len(text) <= MAX_STORED_REASONING_CHARS: + return text + half = MAX_STORED_REASONING_CHARS // 2 + head = text[:half] + tail = text[-half:] + omitted = len(text) - len(head) - len(tail) + return head + _REASONING_TRUNCATE_MARKER.format(omitted=omitted) + tail + + +def _parse_tool_args(args_str: str, finish_reason: Optional[str]) -> Tuple[dict, Optional[str]]: + """Parse tool args JSON. Returns (args, error_msg); error_msg is None on success. + + On JSONDecodeError: detect truncation first (skip repair, surface max_tokens hint); + otherwise try json-repair for escape issues; finally fall back to the raw decoder error. + """ + if not args_str: + return {}, None + try: + return json.loads(args_str), None + except json.JSONDecodeError as e: + if finish_reason in ("length", "max_tokens") or not args_str.rstrip().endswith("}"): + return {}, "Output truncated (max_tokens reached). Split content into smaller chunks across multiple tool calls." + if _HAS_JSON_REPAIR: + try: + repaired = _repair_json(args_str, return_objects=True) + if isinstance(repaired, dict): + logger.warning(f"Tool args JSON repaired ({len(args_str)} chars)") + return repaired, None + except Exception: + pass + return {}, f"Invalid JSON in tool arguments: {e.msg}" + + +class AgentStreamExecutor: + """ + Agent Stream Executor + + Handles multi-turn reasoning loop based on tool-call: + 1. LLM generates response (may include tool calls) + 2. Execute tools + 3. Return results to LLM + 4. Repeat until no more tool calls + """ + + def __init__( + self, + agent, # Agent instance + model: LLMModel, + system_prompt: str, + tools: List[BaseTool], + max_turns: int = 50, + on_event: Optional[Callable] = None, + messages: Optional[List[Dict]] = None, + max_context_turns: int = 30, + cancel_event=None, + ): + """ + Initialize stream executor + + Args: + agent: Agent instance (for accessing context) + model: LLM model + system_prompt: System prompt + tools: List of available tools + max_turns: Maximum number of turns + on_event: Event callback function + messages: Optional existing message history (for persistent conversations) + max_context_turns: Maximum number of conversation turns to keep in context + cancel_event: Optional threading.Event used to signal user cancel. + Checked at every safe point (turn boundary, before tool execution, + during LLM streaming). When set, raises AgentCancelledError which + run_stream catches to gracefully wind down. + """ + self.agent = agent + self.model = model + self.system_prompt = system_prompt + # Convert tools list to dict + self.tools = {tool.name: tool for tool in tools} if isinstance(tools, list) else tools + self.max_turns = max_turns + self.on_event = on_event + self.max_context_turns = max_context_turns + self.cancel_event = cancel_event + + # Message history - use provided messages or create new list + self.messages = messages if messages is not None else [] + + # Tool failure tracking for retry protection + self.tool_failure_history = [] # List of (tool_name, args_hash, success) tuples + + # Track files to send (populated by read tool) + self.files_to_send = [] # List of file metadata dicts + + def _check_cancelled(self) -> None: + """Raise AgentCancelledError if the user requested cancellation. + + Called at safe points (turn start, between tool calls, between LLM + chunks). Cheap to call: just an Event.is_set() probe. + """ + if self.cancel_event is not None and self.cancel_event.is_set(): + raise AgentCancelledError("agent cancelled by user") + + def _handle_cancelled(self, partial_response: str) -> None: + """Wind down ``self.messages`` after a user-initiated cancel. + + The messages list may be in any of these states when we get here: + (a) Last message is an assistant message containing tool_use + blocks but the matching tool_result has not been appended yet. + (b) Last message is an assistant text-only reply (cancel happened + right before the next turn started). + (c) Last message is a user tool_result message and we cancelled + between turns. + + For (a) we MUST synthesise tool_result blocks, otherwise the next + request will fail Claude/OpenAI's strict pairing validation. For + (b)/(c) the state is already valid and we just append a small + cancellation note so the user/LLM both see the boundary clearly. + """ + try: + # Step 1: close any orphaned tool_use in the trailing assistant + # message by injecting matching tool_result blocks. + if self.messages and isinstance(self.messages[-1], dict) \ + and self.messages[-1].get("role") == "assistant": + last = self.messages[-1] + content = last.get("content") + if isinstance(content, list): + pending_tool_use_ids = [ + block.get("id") + for block in content + if isinstance(block, dict) and block.get("type") == "tool_use" + ] + pending_tool_use_ids = [tid for tid in pending_tool_use_ids if tid] + if pending_tool_use_ids: + tool_result_blocks = [ + { + "type": "tool_result", + "tool_use_id": tid, + "content": "Cancelled by user before this tool finished.", + "is_error": True, + } + for tid in pending_tool_use_ids + ] + self.messages.append({ + "role": "user", + "content": tool_result_blocks, + }) + logger.info( + f"[Agent] Injected {len(tool_result_blocks)} cancellation " + f"tool_result blocks to keep message history valid" + ) + + # Step 2: append a stable "interrupted" marker so the LLM sees a + # clear stop boundary on the next turn. + self.messages.append({ + "role": "assistant", + "content": [{"type": "text", "text": "_(Cancelled by user)_"}], + }) + except Exception as e: + logger.warning(f"[Agent] _handle_cancelled cleanup failed: {e}") + + def _emit_event(self, event_type: str, data: dict = None): + """Emit event""" + if self.on_event: + try: + self.on_event({ + "type": event_type, + "timestamp": time.time(), + "data": data or {} + }) + except Exception as e: + logger.error(f"Event callback error: {e}") + + def _is_thinking_enabled(self) -> bool: + """Whether deep-thinking mode is on at the model layer. + + Mirrors the global toggle used by ``bridge.agent_bridge`` when deciding + whether to send ``thinking={"type": "enabled"}`` to the model. Used for + logging and reasoning-update event emission across all channels. + """ + from config import conf + return bool(conf().get("enable_thinking", False)) + + def _should_render_thinking_inline(self) -> bool: + """Whether ``...`` blocks embedded directly in ``content`` + (MiniMax, some third-party proxies) should be surfaced to the channel. + + Only the Web console can render them in a collapsible panel. IM channels + (WeChat/WeCom/DingTalk/Feishu) must strip them, otherwise users see raw + XML tags in their chat. + """ + from config import conf + channel_type = getattr(self.model, 'channel_type', '') or '' + return conf().get("enable_thinking", False) and channel_type == 'web' + + def _filter_think_tags(self, text: str) -> str: + """ + Handle ... blocks in content returned by some LLM providers + (e.g., MiniMax). + + - When inline thinking rendering is allowed (Web + thinking enabled): + remove only the tags, keep the content inside. + - Otherwise (IM channels, or thinking disabled globally): remove both + the tags and the content entirely. + """ + if not text: + return text + import re + if self._should_render_thinking_inline(): + text = re.sub(r'', '', text) + text = re.sub(r'', '', text) + else: + text = re.sub(r'[\s\S]*?', '', text) + # Also strip unclosed tag at the end (streaming partial) + text = re.sub(r'[\s\S]*$', '', text) + return text + + def _hash_args(self, args: dict) -> str: + """Generate a simple hash for tool arguments""" + import hashlib + # Sort keys for consistent hashing + args_str = json.dumps(args, sort_keys=True, ensure_ascii=False) + return hashlib.md5(args_str.encode()).hexdigest()[:8] + + def _check_consecutive_failures(self, tool_name: str, args: dict) -> Tuple[bool, str, bool]: + """ + Check if tool has failed too many times consecutively or called repeatedly with same args + + Returns: + (should_stop, reason, is_critical) + - should_stop: Whether to stop tool execution + - reason: Reason for stopping + - is_critical: Whether to abort entire conversation (True for 8+ failures) + """ + args_hash = self._hash_args(args) + + # Count consecutive calls (both success and failure) for same tool + args + # This catches infinite loops where tool succeeds but LLM keeps calling it + same_args_calls = 0 + for name, ahash, success in reversed(self.tool_failure_history): + if name == tool_name and ahash == args_hash: + same_args_calls += 1 + else: + break # Different tool or args, stop counting + + # Stop at 5 consecutive calls with same args (whether success or failure) + if same_args_calls >= 5: + return True, f"工具 '{tool_name}' 使用相同参数已被调用 {same_args_calls} 次,停止执行以防止无限循环。如果需要查看配置,结果已在之前的调用中返回。", False + + # Count consecutive failures for same tool + args + same_args_failures = 0 + for name, ahash, success in reversed(self.tool_failure_history): + if name == tool_name and ahash == args_hash: + if not success: + same_args_failures += 1 + else: + break # Stop at first success + else: + break # Different tool or args, stop counting + + if same_args_failures >= 3: + return True, f"工具 '{tool_name}' 使用相同参数连续失败 {same_args_failures} 次,停止执行以防止无限循环", False + + # Count consecutive failures for same tool (any args) + same_tool_failures = 0 + for name, ahash, success in reversed(self.tool_failure_history): + if name == tool_name: + if not success: + same_tool_failures += 1 + else: + break # Stop at first success + else: + break # Different tool, stop counting + + # Hard stop at 8 failures - abort with critical message + if same_tool_failures >= 8: + return True, _t( + "抱歉,我没能完成这个任务。可能是我理解有误或者当前方法不太合适。\n\n建议你:\n• 换个方式描述需求试试\n• 把任务拆分成更小的步骤\n• 或者换个思路来解决", + "Sorry, I couldn't complete this task. I may have misunderstood, or my current approach isn't quite right.\n\nYou could try:\n• Rephrasing your request\n• Breaking the task into smaller steps\n• Taking a different approach", + ), True + + # Warning at 6 failures + if same_tool_failures >= 6: + return True, f"工具 '{tool_name}' 连续失败 {same_tool_failures} 次(使用不同参数),停止执行以防止无限循环", False + + return False, "", False + + def _record_tool_result(self, tool_name: str, args: dict, success: bool): + """Record tool execution result for failure tracking""" + args_hash = self._hash_args(args) + self.tool_failure_history.append((tool_name, args_hash, success)) + # Keep only last 50 records to avoid memory bloat + if len(self.tool_failure_history) > 50: + self.tool_failure_history = self.tool_failure_history[-50:] + + def run_stream(self, user_message: str) -> str: + """ + Execute streaming reasoning loop + + Args: + user_message: User message + + Returns: + Final response text + """ + # Log user message with model info. Truncate very long messages (e.g. + # injected transcripts / large prompts) so logs stay readable. + thinking_enabled = self._is_thinking_enabled() + thinking_label = " | 💭 thinking" if thinking_enabled else "" + _log_msg = user_message if len(user_message) <= 500 else ( + user_message[:500] + f" …(+{len(user_message) - 500} chars)" + ) + logger.info(f"🤖 {self.model.model}{thinking_label} | 👤 {_log_msg}") + + # Add user message (Claude format - use content blocks for consistency) + self.messages.append({ + "role": "user", + "content": [ + { + "type": "text", + "text": user_message + } + ] + }) + + # Trim context ONCE before the agent loop starts, not during tool steps. + # This ensures tool_use/tool_result chains created during the current run + # are never stripped mid-execution (which would cause LLM loops). + self._trim_messages() + + # Validate after trimming: trimming may leave orphaned tool_use at the + # boundary (e.g. the last kept turn ends with an assistant tool_use whose + # tool_result was in a discarded turn). + self._validate_and_fix_messages() + + self._emit_event("agent_start") + + # Reset the run-scoped MCP tool-retrieval accumulator. On-demand tool + # retrieval only grows this set within a run, so a tool that already + # produced a tool_use never disappears from the schema mid-run (which + # would make Claude/MiniMax raise a message-format error). + self._retrieved_mcp_names = set() + + final_response = "" + turn = 0 + + cancelled = False + try: + while turn < self.max_turns: + # Check at the very top of every turn so a cancel arriving + # between turns short-circuits cleanly. + self._check_cancelled() + + turn += 1 + logger.info(f"[Agent] Turn {turn}") + self._emit_event("turn_start", {"turn": turn}) + + # Call LLM (enable retry_on_empty for better reliability) + assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=True) + final_response = assistant_msg + + # No tool calls, end loop + if not tool_calls: + # 检查是否返回了空响应 + if not assistant_msg: + logger.warning(f"[Agent] LLM returned empty response after retry (no content and no tool calls)") + logger.info(f"[Agent] This usually happens when LLM thinks the task is complete after tool execution") + + # 如果之前有工具调用,强制要求 LLM 生成文本回复 + if turn > 1: + logger.info(f"[Agent] Requesting explicit response from LLM...") + + # Remember position so we can remove the injected prompt later + prompt_insert_idx = len(self.messages) + + # 添加一条消息,明确要求回复用户 + self.messages.append({ + "role": "user", + "content": [{ + "type": "text", + "text": "请向用户说明刚才工具执行的结果或回答用户的问题。" + }] + }) + + # 再调用一次 LLM + assistant_msg, tool_calls = self._call_llm_stream(retry_on_empty=False) + final_response = assistant_msg + + # Remove the injected prompt from history so it doesn't + # appear as a user message in persisted conversations. + # _call_llm_stream may have appended an assistant message + # after the prompt, so we locate and remove only the prompt. + if (prompt_insert_idx < len(self.messages) + and self.messages[prompt_insert_idx].get("role") == "user"): + self.messages.pop(prompt_insert_idx) + logger.debug("[Agent] Removed injected explicit-response prompt from message history") + + # If LLM responded with tool_calls instead of text, fall through + # to the tool execution path below (don't break the loop). + if tool_calls: + logger.info( + f"[Agent] LLM returned tool_calls in explicit-response retry, " + f"continuing to execute tools instead of breaking" + ) + elif not assistant_msg: + # Still empty (no text and no tool_calls): use fallback + logger.warning(f"[Agent] Still empty after explicit request") + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", + ) + logger.info(f"Generated fallback response for empty LLM output") + else: + # First-turn empty reply, fall back directly + final_response = _t( + "抱歉,我暂时无法生成回复。请尝试换一种方式描述你的需求,或稍后再试。", + "Sorry, I can't generate a reply right now. Please try rephrasing your request, or try again later.", + ) + logger.info(f"Generated fallback response for empty LLM output") + else: + logger.info(f"💭 {assistant_msg[:150]}{'...' if len(assistant_msg) > 150 else ''}") + + # If the explicit-response retry produced tool_calls, skip the break + # and continue down to the tool execution branch in this same iteration. + if not tool_calls: + logger.debug(f"✅ Done (no tool calls)") + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": False + }) + break + + # Log tool calls with arguments (truncate long values like base64) + tool_calls_str = [] + for tc in tool_calls: + args = tc.get('arguments') or {} + if isinstance(args, dict): + parts = [] + for k, v in args.items(): + v_str = str(v) + if len(v_str) > 200: + v_str = v_str[:200] + f"...({len(v_str)} chars)" + parts.append(f"{k}={v_str}") + args_str = ', '.join(parts) + if args_str: + tool_calls_str.append(f"{tc['name']}({args_str})") + else: + tool_calls_str.append(tc['name']) + else: + tool_calls_str.append(tc['name']) + logger.info(f"🔧 {', '.join(tool_calls_str)}") + + # Execute tools + tool_results = [] + tool_result_blocks = [] + + try: + for tool_call in tool_calls: + # Honour cancel between tool invocations within the same turn + self._check_cancelled() + result = self._execute_tool(tool_call) + tool_results.append(result) + + # Debug: Check if tool is being called repeatedly with same args + if turn > 2: + # Check last N tool calls for repeats + repeat_count = sum( + 1 for name, ahash, _ in self.tool_failure_history[-10:] + if name == tool_call["name"] and ahash == self._hash_args(tool_call["arguments"]) + ) + if repeat_count >= 3: + logger.warning( + f"⚠️ Tool '{tool_call['name']}' has been called {repeat_count} times " + f"with same arguments. This may indicate a loop." + ) + + # Check if this is a file to send + if result.get("status") == "success" and isinstance(result.get("result"), dict): + result_data = result.get("result") + if result_data.get("type") == "file_to_send": + self.files_to_send.append(result_data) + logger.info(f"📎 File queued for sending: {result_data.get('file_name', result_data.get('path'))}") + self._emit_event("file_to_send", result_data) + + # Check for critical error - abort entire conversation + if result.get("status") == "critical_error": + logger.error(f"💥 Fatal error detected, aborting conversation") + final_response = result.get('result') or _t("任务执行失败", "Task execution failed") + return final_response + + # Log tool result in compact format + status_emoji = "✅" if result.get("status") == "success" else "❌" + result_data = result.get('result', '') + # Format result string with proper Chinese character support + if isinstance(result_data, (dict, list)): + result_str = json.dumps(result_data, ensure_ascii=False) + else: + result_str = str(result_data) + logger.info(f" {status_emoji} {tool_call['name']} ({result.get('execution_time', 0):.2f}s): {result_str[:200]}{'...' if len(result_str) > 200 else ''}") + + # Build tool result block (Claude format) + # Format content in a way that's easy for LLM to understand + is_error = result.get("status") == "error" + + if is_error: + # For errors, provide clear error message + result_content = f"Error: {result.get('result', 'Unknown error')}" + elif isinstance(result.get('result'), dict): + # For dict results, use JSON format + result_content = json.dumps(result.get('result'), ensure_ascii=False) + elif isinstance(result.get('result'), str): + # For string results, use directly + result_content = result.get('result') + else: + # Fallback to full JSON + result_content = json.dumps(result, ensure_ascii=False) + + # Truncate excessively large tool results for the current turn + # Historical turns will be further truncated in _trim_messages() + MAX_CURRENT_TURN_RESULT_CHARS = 50000 + if len(result_content) > MAX_CURRENT_TURN_RESULT_CHARS: + truncated_len = len(result_content) + result_content = result_content[:MAX_CURRENT_TURN_RESULT_CHARS] + \ + f"\n\n[Output truncated: {truncated_len} chars total, showing first {MAX_CURRENT_TURN_RESULT_CHARS} chars]" + logger.info(f"📎 Truncated tool result for '{tool_call['name']}': {truncated_len} -> {MAX_CURRENT_TURN_RESULT_CHARS} chars") + + tool_result_block = { + "type": "tool_result", + "tool_use_id": tool_call["id"], + "content": result_content + } + + # Add is_error field for Claude API (helps model understand failures) + if is_error: + tool_result_block["is_error"] = True + + tool_result_blocks.append(tool_result_block) + + finally: + # CRITICAL: Always add tool_result to maintain message history integrity + # Even if tool execution fails, we must add error results to match tool_use + if tool_result_blocks: + # Add tool results to message history as user message (Claude format) + self.messages.append({ + "role": "user", + "content": tool_result_blocks + }) + + # Detect potential infinite loop: same tool called multiple times with success + # If detected, add a hint to LLM to stop calling tools and provide response + if turn >= 3 and len(tool_calls) > 0: + tool_name = tool_calls[0]["name"] + args_hash = self._hash_args(tool_calls[0]["arguments"]) + + # Count recent successful calls with same tool+args + recent_success_count = 0 + for name, ahash, success in reversed(self.tool_failure_history[-10:]): + if name == tool_name and ahash == args_hash and success: + recent_success_count += 1 + + # If tool was called successfully 3+ times with same args, add hint to stop loop + if recent_success_count >= 3: + logger.warning( + f"⚠️ Detected potential loop: '{tool_name}' called {recent_success_count} times " + f"with same args. Adding hint to LLM to provide final response." + ) + # Add a gentle hint message to guide LLM to respond + self.messages.append({ + "role": "user", + "content": [{ + "type": "text", + "text": "工具已成功执行并返回结果。请基于这些信息向用户做出回复,不要重复调用相同的工具。" + }] + }) + elif tool_calls: + # If we have tool_calls but no tool_result_blocks (unexpected error), + # create error results for all tool calls to maintain message integrity + logger.warning("⚠️ Tool execution interrupted, adding error results to maintain message history") + emergency_blocks = [] + for tool_call in tool_calls: + emergency_blocks.append({ + "type": "tool_result", + "tool_use_id": tool_call["id"], + "content": "Error: Tool execution was interrupted", + "is_error": True + }) + self.messages.append({ + "role": "user", + "content": emergency_blocks + }) + + self._emit_event("turn_end", { + "turn": turn, + "has_tool_calls": True, + "tool_count": len(tool_calls) + }) + + if turn >= self.max_turns: + logger.warning(f"⚠️ Reached max decision step limit: {self.max_turns}") + + # Force model to summarize without tool calls + logger.info(f"[Agent] Requesting summary from LLM after reaching max steps...") + + # Remember position before injecting the prompt so we can remove it later + prompt_insert_idx = len(self.messages) + + # Add a temporary prompt to force summary + self.messages.append({ + "role": "user", + "content": [{ + "type": "text", + "text": f"你已经执行了{turn}个决策步骤,达到了单次运行的最大步数限制。请总结一下你目前的执行过程和结果,告诉用户当前的进展情况。不要再调用工具,直接用文字回复。" + }] + }) + + # Call LLM one more time to get summary (without retry to avoid loops) + try: + summary_response, summary_tools = self._call_llm_stream(retry_on_empty=False) + if summary_response: + final_response = summary_response + logger.info(f"💭 Summary: {summary_response[:150]}{'...' if len(summary_response) > 150 else ''}") + else: + # Fallback if model still doesn't respond + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", + ) + except Exception as e: + logger.warning(f"Failed to get summary from LLM: {e}") + final_response = _t( + f"我已经执行了{turn}个决策步骤,达到了单次运行的步数上限。任务可能还未完全完成,建议你将任务拆分成更小的步骤,或者换一种方式描述需求。", + f"I've taken {turn} decision steps and reached the per-run limit. The task may not be fully complete — try breaking it into smaller steps, or describe your request differently.", + ) + finally: + # Remove the injected user prompt from history to avoid polluting + # persisted conversation records. The assistant summary (if any) + # was already appended by _call_llm_stream and is kept. + if (prompt_insert_idx < len(self.messages) + and self.messages[prompt_insert_idx].get("role") == "user"): + self.messages.pop(prompt_insert_idx) + logger.debug("[Agent] Removed injected max-steps prompt from message history") + + except AgentCancelledError: + # User-initiated stop: wind down message history cleanly so the + # next turn is unaffected; channels emit a "cancelled" UI event. + cancelled = True + logger.info(f"[Agent] 🛑 Cancelled by user (turn {turn})") + self._handle_cancelled(final_response) + if not final_response or not final_response.strip(): + final_response = "_(Cancelled)_" + + except Exception as e: + logger.error(f"❌ Agent execution error: {e}") + self._emit_event("error", {"error": str(e)}) + raise + + finally: + final_response = final_response.strip() if final_response else final_response + if cancelled: + # Emit before agent_end so channels can mark UI as cancelled + self._emit_event("agent_cancelled", {"final_response": final_response}) + logger.info(f"[Agent] 🏁 Done ({turn} turns)" + (" [cancelled]" if cancelled else "")) + self._emit_event("agent_end", {"final_response": final_response, "cancelled": cancelled}) + + return final_response + + def _select_tools_for_injection(self) -> list: + """Decide which tools to inject into the current LLM turn. + + Built-in tools are ALWAYS injected in full (skills and core flows hard + depend on them). MCP tools are also injected in full UNLESS on-demand + retrieval is enabled AND the MCP tool count exceeds the configured + threshold — then only the most relevant MCP tools are injected, unioned + with those already selected earlier in this run (only-grows, so a tool + that already produced a tool_use never vanishes from the schema). + + Degrades safely: disabled feature, no embedding provider, embedding + failure, count below threshold, or any error → inject all tools. Tools + are never silently dropped. + """ + all_tools = list(self.tools.values()) + try: + from config import conf + if not conf().get("mcp_tool_retrieval_enabled", False): + return all_tools + + from agent.tools.mcp.mcp_tool import McpTool + mcp_tools = [t for t in all_tools if isinstance(t, McpTool)] + builtin_tools = [t for t in all_tools if not isinstance(t, McpTool)] + + threshold = int(conf().get("mcp_tool_retrieval_threshold", 20) or 20) + if len(mcp_tools) <= threshold: + return all_tools + + top_k = int(conf().get("mcp_tool_retrieval_top_k", 10) or 10) + + from agent.tools import ToolManager + from agent.tools.mcp.tool_retrieval import ( + build_retrieval_query, + select_mcp_tools, + ) + + tm = ToolManager() + tool_vectors = tm.get_mcp_tool_vectors() + query = build_retrieval_query(self.messages) + query_vector = tm.embed_query(query) + + selected = select_mcp_tools( + query_vector, + tool_vectors, + top_k, + getattr(self, "_retrieved_mcp_names", set()), + ) + if selected is None: + # No provider / empty index / error → full injection. + return all_tools + + # Persist the accumulated selection for subsequent turns. + self._retrieved_mcp_names = selected + + selected_mcp = [t for t in mcp_tools if t.name in selected] + logger.info( + f"[ToolRetrieval] Injecting {len(builtin_tools)} built-in + " + f"{len(selected_mcp)}/{len(mcp_tools)} MCP tool(s) (top_k={top_k})" + ) + return builtin_tools + selected_mcp + except Exception as e: + logger.debug(f"[ToolRetrieval] full injection (retrieval skipped): {e}") + return all_tools + + def _call_llm_stream(self, retry_on_empty=True, retry_count=0, max_retries=3, + _overflow_retry: bool = False) -> Tuple[str, List[Dict]]: + """ + Call LLM with streaming and automatic retry on errors + + Args: + retry_on_empty: Whether to retry once if empty response is received + retry_count: Current retry attempt (internal use) + max_retries: Maximum number of retries for API errors + _overflow_retry: Internal flag indicating this is a retry after context overflow + + Returns: + (response_text, tool_calls) + """ + # Validate and fix message history (e.g. orphaned tool_result blocks). + # Context trimming is done once in run_stream() before the loop starts, + # NOT here — trimming mid-execution would strip the current run's + # tool_use/tool_result chains and cause LLM loops. + self._validate_and_fix_messages() + + # Prepare messages + messages = self._prepare_messages() + turns = self._identify_complete_turns() + logger.info(f"Sending {len(messages)} messages ({len(turns)} turns) to LLM") + + # Pull in any MCP tools that finished loading since this turn started. + # Cheap dict reconciliation (microseconds) — lets the agent pick up + # newly available MCP tools mid-conversation without a session restart. + try: + from agent.tools import ToolManager + ToolManager().sync_mcp_into_agent(self) + except Exception as e: + logger.debug(f"[Agent] MCP sync skipped: {e}") + + # Prepare tool definitions. Prefer get_json_schema() when it yields + # real properties (lets tools augment schema at runtime), otherwise + # fall back to the static `tool.params` (MCP tools rely on this). + tools_schema = None + if self.tools: + tools_schema = [] + for tool in self._select_tools_for_injection(): + input_schema = tool.params + try: + dynamic = (tool.get_json_schema() or {}).get("parameters") or {} + if dynamic.get("properties"): + input_schema = dynamic + except Exception: + pass + tools_schema.append({ + "name": tool.name, + "description": tool.description, + "input_schema": input_schema, + }) + + # Debug: dump the full system prompt and messages sent to the LLM. + # Gated behind `debug` config to avoid flooding normal logs. + # try: + # from config import conf + # if conf().get("debug", False): + # logger.debug( + # "[Agent][debug] system_prompt sent to LLM " + # f"({len(self.system_prompt or '')} chars):\n" + # "================ SYSTEM PROMPT BEGIN ================\n" + # f"{self.system_prompt}\n" + # "================ SYSTEM PROMPT END ==================" + # ) + # logger.info(f"[Agent][debug] messages sent to LLM: {messages}") + # except Exception: + # pass + + # Create request + request = LLMRequest( + messages=messages, + temperature=0, + stream=True, + tools=tools_schema, + system=self.system_prompt # Pass system prompt separately for Claude API + ) + + self._emit_event("message_start", {"role": "assistant"}) + + # Streaming response + full_content = "" + full_reasoning = "" + tool_calls_buffer = {} # {index: {id, name, arguments}} + gemini_raw_parts = None # Preserve Gemini thoughtSignature for round-trip + stop_reason = None # Track why the stream stopped + + try: + stream = self.model.call_stream(request) + + # Probe cancel every N chunks to bound reaction time without + # checking on every token. + _cancel_probe_counter = 0 + _CANCEL_PROBE_EVERY = 8 + + for chunk in stream: + _cancel_probe_counter += 1 + if _cancel_probe_counter >= _CANCEL_PROBE_EVERY: + _cancel_probe_counter = 0 + if self.cancel_event is not None and self.cancel_event.is_set(): + # Persist partial text only; tool_use args may be + # truncated mid-stream and would fail validation. + logger.info("[Agent] cancel detected mid-stream, aborting LLM call") + if full_content: + partial_msg = { + "role": "assistant", + "content": [{"type": "text", "text": full_content}], + } + self.messages.append(partial_msg) + self._emit_event("message_end", { + "content": full_content, + "tool_calls": [], + "cancelled": True, + }) + raise AgentCancelledError("cancelled during LLM streaming") + + # Check for errors + if isinstance(chunk, dict) and chunk.get("error"): + # Extract error message from nested structure + error_data = chunk.get("error", {}) + if isinstance(error_data, dict): + error_msg = error_data.get("message", chunk.get("message", "Unknown error")) + error_code = error_data.get("code", "") + error_type = error_data.get("type", "") + else: + error_msg = chunk.get("message", str(error_data)) + error_code = "" + error_type = "" + + status_code = chunk.get("status_code", "N/A") + + # Log error with all available information + logger.error(f"🔴 Stream API Error:") + logger.error(f" Message: {error_msg}") + logger.error(f" Status Code: {status_code}") + logger.error(f" Error Code: {error_code}") + logger.error(f" Error Type: {error_type}") + logger.error(f" Full chunk: {chunk}") + + # Check if this is a context overflow error (keyword-based, works for all models) + # Don't rely on specific status codes as different providers use different codes + error_msg_lower = error_msg.lower() + is_overflow = any(keyword in error_msg_lower for keyword in [ + 'context length exceeded', 'maximum context length', 'prompt is too long', + 'context overflow', 'context window', 'too large', 'exceeds model context', + 'request_too_large', 'request exceeds the maximum size', 'tokens exceed' + ]) + + if is_overflow: + # Mark as context overflow for special handling + raise Exception(f"[CONTEXT_OVERFLOW] {error_msg} (Status: {status_code})") + else: + # Raise exception with full error message for retry logic + raise Exception(f"{error_msg} (Status: {status_code}, Code: {error_code}, Type: {error_type})") + + # Parse chunk + if isinstance(chunk, dict) and chunk.get("choices"): + choice = chunk["choices"][0] + delta = choice.get("delta", {}) + + # Capture finish_reason if present + finish_reason = choice.get("finish_reason") + if finish_reason: + stop_reason = finish_reason + + reasoning_delta = delta.get("reasoning_content") or "" + if reasoning_delta: + full_reasoning += reasoning_delta + if self._is_thinking_enabled(): + self._emit_event("reasoning_update", {"delta": reasoning_delta}) + + # Handle text content + content_delta = delta.get("content") or "" + if content_delta: + # Filter out tags from content + filtered_delta = self._filter_think_tags(content_delta) + full_content += filtered_delta + if filtered_delta: # Only emit if there's content after filtering + self._emit_event("message_update", {"delta": filtered_delta}) + + # Handle tool calls + if "tool_calls" in delta and delta["tool_calls"]: + for tc_delta in delta["tool_calls"]: + index = tc_delta.get("index", 0) + + if index not in tool_calls_buffer: + tool_calls_buffer[index] = { + "id": "", + "name": "", + "arguments": "" + } + + if tc_delta.get("id"): + tool_calls_buffer[index]["id"] = tc_delta["id"] + + if "function" in tc_delta: + func = tc_delta["function"] + if func.get("name"): + tool_calls_buffer[index]["name"] = func["name"] + if func.get("arguments"): + tool_calls_buffer[index]["arguments"] += func["arguments"] + + # Preserve _gemini_raw_parts for Gemini thoughtSignature round-trip + # (direct Gemini: list of parts; LinkAI proxy: base64 string of JSON parts) + if "_gemini_raw_parts" in delta: + gemini_raw_parts = delta["_gemini_raw_parts"] + elif isinstance(choice, dict) and choice.get("_gemini_raw_parts"): + gemini_raw_parts = choice["_gemini_raw_parts"] + + except AgentCancelledError: + # Must propagate untouched; never treat as a retryable error. + raise + + except Exception as e: + error_str = str(e) + error_str_lower = error_str.lower() + + # Check if error is context overflow (non-retryable, needs session reset) + # Method 1: Check for special marker (set in stream error handling above) + is_context_overflow = '[context_overflow]' in error_str_lower + + # Method 2: Fallback to keyword matching for non-stream errors + if not is_context_overflow: + is_context_overflow = any(keyword in error_str_lower for keyword in [ + 'context length exceeded', 'maximum context length', 'prompt is too long', + 'context overflow', 'context window', 'too large', 'exceeds model context', + 'request_too_large', 'request exceeds the maximum size' + ]) + + # Check if error is message format error (incomplete tool_use/tool_result pairs) + # This happens when previous conversation had tool failures or context trimming + # broke tool_use/tool_result pairs. + # Note: MiniMax returns error 2013 "tool result's tool id(...) not found" for + # tool_call_id mismatches — the keywords below are intentionally broad to catch + # both standard (Claude/OpenAI) and provider-specific (MiniMax) variants. + is_message_format_error = any(keyword in error_str_lower for keyword in [ + 'tool_use', 'tool_result', 'tool result', 'without', 'immediately after', + 'corresponding', 'must have', 'each', + 'tool_call_id', 'tool id', 'is not found', 'not found', 'tool_calls', + 'must be a response to a preceeding message', + '2013', # MiniMax error code for tool_call_id mismatch + ]) and ('400' in error_str_lower or 'status: 400' in error_str_lower + or 'invalid_request' in error_str_lower + or 'invalidparameter' in error_str_lower) + + if is_context_overflow or is_message_format_error: + error_type = "context overflow" if is_context_overflow else "message format error" + logger.error(f"💥 {error_type} detected: {e}") + + # Flush memory before trimming to preserve context that will be lost + if is_context_overflow and self.agent.memory_manager: + user_id = getattr(self.agent, '_current_user_id', None) + self.agent.memory_manager.flush_memory( + messages=self.messages, user_id=user_id, + reason="overflow", max_messages=0 + ) + + # Strategy: try aggressive trimming first, only clear as last resort + if is_context_overflow and not _overflow_retry: + trimmed = self._aggressive_trim_for_overflow() + if trimmed: + logger.warning("🔄 Aggressively trimmed context, retrying...") + return self._call_llm_stream( + retry_on_empty=retry_on_empty, + retry_count=retry_count, + max_retries=max_retries, + _overflow_retry=True + ) + + # Aggressive trim didn't help or this is a message format error + # -> clear everything and also purge DB to prevent reload of dirty data + logger.warning("🔄 Clearing conversation history to recover") + self.messages.clear() + self._clear_session_db() + if is_context_overflow: + raise Exception(_t( + "抱歉,对话历史过长导致上下文溢出。我已清空历史记录,请重新描述你的需求。", + "Sorry, the conversation history got too long and overflowed the context. I've cleared the history — please describe your request again.", + )) + else: + raise Exception(_t( + "抱歉,之前的对话出现了问题。我已清空历史记录,请重新发送你的消息。", + "Sorry, something went wrong with the earlier conversation. I've cleared the history — please send your message again.", + )) + + # Check if error is rate limit (429) + is_rate_limit = '429' in error_str_lower or 'rate limit' in error_str_lower + + # Check if error is retryable (timeout, connection, server busy, etc.) + is_retryable = any(keyword in error_str_lower for keyword in [ + 'timeout', 'timed out', 'connection', 'network', + 'rate limit', 'overloaded', 'unavailable', 'busy', 'retry', + '429', '500', '502', '503', '504', '512' + ]) + + if is_retryable and retry_count < max_retries: + # Rate limit needs longer wait time + if is_rate_limit: + wait_time = 30 + (retry_count * 15) # 30s, 45s, 60s for rate limit + else: + wait_time = (retry_count + 1) * 2 # 2s, 4s, 6s for other errors + + logger.warning(f"⚠️ LLM API error (attempt {retry_count + 1}/{max_retries}): {e}") + logger.info(f"Retrying in {wait_time}s...") + time.sleep(wait_time) + return self._call_llm_stream( + retry_on_empty=retry_on_empty, + retry_count=retry_count + 1, + max_retries=max_retries + ) + else: + if retry_count >= max_retries: + logger.error(f"❌ LLM API error after {max_retries} retries: {e}", exc_info=True) + else: + logger.error(f"❌ LLM call error (non-retryable): {e}", exc_info=True) + raise + + # Parse tool calls + tool_calls = [] + for idx in sorted(tool_calls_buffer.keys()): + tc = tool_calls_buffer[idx] + + # Ensure tool call has a valid ID (some providers return empty/None IDs) + tool_id = tc.get("id") or "" + if not tool_id: + import uuid + tool_id = f"call_{uuid.uuid4().hex[:24]}" + + args_str = tc.get("arguments") or "" + arguments, parse_err = _parse_tool_args(args_str, stop_reason) + if parse_err: + logger.error( + f"Tool args parse failed for {tc['name']} ({len(args_str)} chars): {parse_err}" + ) + tool_calls.append({ + "id": tool_id, + "name": tc["name"], + "arguments": {}, + "_parse_error": parse_err, + }) + continue + + tool_calls.append({ + "id": tool_id, + "name": tc["name"], + "arguments": arguments + }) + + # Check for empty response and retry once if enabled + if retry_on_empty and not full_content and not tool_calls: + logger.warning(f"⚠️ LLM returned empty response (stop_reason: {stop_reason}), retrying once...") + self._emit_event("message_end", { + "content": "", + "tool_calls": [], + "empty_retry": True, + "stop_reason": stop_reason + }) + # Retry without retry flag to avoid infinite loop + return self._call_llm_stream( + retry_on_empty=False, + retry_count=retry_count, + max_retries=max_retries + ) + + # Filter full_content one more time (in case tags were split across chunks) + full_content = self._filter_think_tags(full_content) + + # Add assistant message to history (Claude format uses content blocks) + assistant_msg = {"role": "assistant", "content": []} + + if full_reasoning: + stored_reasoning = _truncate_reasoning_for_storage(full_reasoning) + if len(stored_reasoning) < len(full_reasoning): + logger.info( + f"[reasoning] truncated for storage: " + f"{len(full_reasoning)} -> {len(stored_reasoning)} chars" + ) + assistant_msg["content"].append({ + "type": "thinking", + "thinking": stored_reasoning + }) + + if full_content: + assistant_msg["content"].append({ + "type": "text", + "text": full_content + }) + + # Add tool_use blocks if present + if tool_calls: + for tc in tool_calls: + assistant_msg["content"].append({ + "type": "tool_use", + "id": tc.get("id", ""), + "name": tc.get("name", ""), + "input": tc.get("arguments", {}) + }) + + if gemini_raw_parts: + assistant_msg["_gemini_raw_parts"] = gemini_raw_parts + + # Only append if content is not empty + if assistant_msg["content"]: + self.messages.append(assistant_msg) + + self._emit_event("message_end", { + "content": full_content, + "tool_calls": tool_calls + }) + + return full_content, tool_calls + + def _execute_tool(self, tool_call: Dict) -> Dict[str, Any]: + """ + Execute tool + + Args: + tool_call: {"id": str, "name": str, "arguments": dict} + + Returns: + Tool execution result + """ + tool_name = tool_call["name"] + tool_id = tool_call["id"] + arguments = tool_call["arguments"] + + if "_parse_error" in tool_call: + result = { + "status": "error", + "result": tool_call["_parse_error"], + "execution_time": 0, + } + self._record_tool_result(tool_name, arguments, False) + return result + + # Check for consecutive failures (retry protection) + should_stop, stop_reason, is_critical = self._check_consecutive_failures(tool_name, arguments) + if should_stop: + logger.error(f"🛑 {stop_reason}") + self._record_tool_result(tool_name, arguments, False) + + if is_critical: + # Critical failure - abort entire conversation + result = { + "status": "critical_error", + "result": stop_reason, + "execution_time": 0 + } + else: + # Normal failure - let LLM try different approach + result = { + "status": "error", + "result": f"{stop_reason}\n\n当前方法行不通,请尝试完全不同的方法或向用户询问更多信息。", + "execution_time": 0 + } + return result + + self._emit_event("tool_execution_start", { + "tool_call_id": tool_id, + "tool_name": tool_name, + "arguments": arguments + }) + + try: + tool = self.tools.get(tool_name) + if not tool: + raise ValueError(self._build_tool_not_found_message(tool_name)) + + # Set tool context + tool.model = self.model + tool.context = self.agent + tool.progress_callback = lambda message: self._emit_event( + "tool_execution_progress", + { + "tool_call_id": tool_id, + "tool_name": tool_name, + "message": message, + } + ) + + # Execute tool + start_time = time.time() + try: + result: ToolResult = tool.execute_tool(arguments) + finally: + tool.progress_callback = None + execution_time = time.time() - start_time + + result_dict = { + "status": result.status, + "result": result.result, + "execution_time": execution_time + } + + # Record tool result for failure tracking + success = result.status == "success" + self._record_tool_result(tool_name, arguments, success) + + # Auto-refresh skills after skill creation + if tool_name == "bash" and result.status == "success": + command = arguments.get("command", "") + if "init_skill.py" in command and self.agent.skill_manager: + logger.info("Detected skill creation, refreshing skills...") + self.agent.refresh_skills() + logger.info(f"Skills refreshed! Now have {len(self.agent.skill_manager.skills)} skills") + + self._emit_event("tool_execution_end", { + "tool_call_id": tool_id, + "tool_name": tool_name, + **result_dict + }) + + return result_dict + + except Exception as e: + logger.error(f"Tool execution error: {e}") + error_result = { + "status": "error", + "result": str(e), + "execution_time": 0 + } + # Record failure + self._record_tool_result(tool_name, arguments, False) + + self._emit_event("tool_execution_end", { + "tool_call_id": tool_id, + "tool_name": tool_name, + **error_result + }) + return error_result + + def _build_tool_not_found_message(self, tool_name: str) -> str: + """Build a helpful error message when a tool is not found. + + If a skill with the same name exists in skill_manager, read its + SKILL.md and include the content so the LLM knows how to use it. + """ + available_tools = list(self.tools.keys()) + base_msg = f"Tool '{tool_name}' not found. Available tools: {available_tools}" + + skill_manager = getattr(self.agent, 'skill_manager', None) + if not skill_manager: + return base_msg + + skill_entry = skill_manager.get_skill(tool_name) + if not skill_entry: + return base_msg + + skill = skill_entry.skill + skill_md_path = skill.file_path + skill_content = "" + try: + with open(skill_md_path, 'r', encoding='utf-8') as f: + skill_content = f.read() + except Exception: + skill_content = skill.description + + logger.info( + f"[Agent] Tool '{tool_name}' not found, but matched skill '{skill.name}'. " + f"Guiding LLM to use the skill instead." + ) + + return ( + f"Tool '{tool_name}' is not a built-in tool, but a matching skill " + f"'{skill.name}' is available. You should use existing tools (e.g. bash with curl) " + f"to accomplish this task following the skill instructions below:\n\n" + f"--- SKILL: {skill.name} (path: {skill_md_path}) ---\n" + f"{skill_content}\n" + f"--- END SKILL ---\n\n" + f"Available tools: {available_tools}" + ) + + def _validate_and_fix_messages(self): + """Delegate to the shared sanitizer (see message_sanitizer.py).""" + sanitize_claude_messages(self.messages) + + def _identify_complete_turns(self) -> List[Dict]: + """ + 识别完整的对话轮次 + + 一个完整轮次包括: + 1. 用户消息(text) + 2. AI 回复(可能包含 tool_use) + 3. 工具结果(tool_result,如果有) + 4. 后续 AI 回复(如果有) + + Returns: + List of turns, each turn is a dict with 'messages' list + """ + turns = [] + current_turn = {'messages': []} + + for msg in self.messages: + role = msg.get('role') + content = msg.get('content', []) + + if role == 'user': + # Determine if this is a real user query (not a tool_result injection + # or an internal hint message injected by the agent loop). + is_user_query = False + has_tool_result = False + if isinstance(content, list): + has_text = any( + isinstance(block, dict) and block.get('type') == 'text' + for block in content + ) + has_tool_result = any( + isinstance(block, dict) and block.get('type') == 'tool_result' + for block in content + ) + # A message with tool_result is always internal, even if it + # also contains text blocks (shouldn't happen, but be safe). + is_user_query = has_text and not has_tool_result + elif isinstance(content, str): + is_user_query = True + + if is_user_query: + if current_turn['messages']: + turns.append(current_turn) + current_turn = {'messages': [msg]} + else: + current_turn['messages'].append(msg) + else: + # AI 回复,属于当前轮次 + current_turn['messages'].append(msg) + + # 添加最后一个轮次 + if current_turn['messages']: + turns.append(current_turn) + + return turns + + def _estimate_turn_tokens(self, turn: Dict) -> int: + """估算一个轮次的 tokens""" + return sum( + self.agent._estimate_message_tokens(msg) + for msg in turn['messages'] + ) + + def _truncate_historical_tool_results(self): + """ + Truncate tool_result content in historical messages to reduce context size. + + Current turn results are kept at 30K chars (truncated at creation time). + Historical turn results are further truncated to 10K chars here. + This runs before token-based trimming so that we first shrink oversized + results, potentially avoiding the need to drop entire turns. + """ + MAX_HISTORY_RESULT_CHARS = 20000 + + if len(self.messages) < 2: + return + + # Find where the last user text message starts (= current turn boundary) + # We skip the current turn's messages to preserve their full content + current_turn_start = len(self.messages) + for i in range(len(self.messages) - 1, -1, -1): + msg = self.messages[i] + if msg.get("role") == "user": + content = msg.get("content", []) + if isinstance(content, list) and any( + isinstance(b, dict) and b.get("type") == "text" for b in content + ): + current_turn_start = i + break + elif isinstance(content, str): + current_turn_start = i + break + + truncated_count = 0 + for i in range(current_turn_start): + msg = self.messages[i] + if msg.get("role") != "user": + continue + content = msg.get("content", []) + if not isinstance(content, list): + continue + + for block in content: + if not isinstance(block, dict) or block.get("type") != "tool_result": + continue + result_str = block.get("content", "") + if isinstance(result_str, str) and len(result_str) > MAX_HISTORY_RESULT_CHARS: + original_len = len(result_str) + block["content"] = result_str[:MAX_HISTORY_RESULT_CHARS] + \ + f"\n\n[Historical output truncated: {original_len} -> {MAX_HISTORY_RESULT_CHARS} chars]" + truncated_count += 1 + + if truncated_count > 0: + logger.info(f"📎 Truncated {truncated_count} historical tool result(s) to {MAX_HISTORY_RESULT_CHARS} chars") + + def _aggressive_trim_for_overflow(self) -> bool: + """ + Aggressively trim context when a real overflow error is returned by the API. + + This method goes beyond normal _trim_messages by: + 1. Truncating all tool results (including current turn) to a small limit + 2. Keeping only the last 5 complete conversation turns + 3. Truncating overly long user messages + + Returns: + True if messages were trimmed (worth retrying), False if nothing left to trim + """ + if not self.messages: + return False + + original_count = len(self.messages) + + # Step 1: Aggressively truncate ALL tool results to 5K chars + AGGRESSIVE_LIMIT = 10000 + truncated = 0 + for msg in self.messages: + content = msg.get("content", []) + if not isinstance(content, list): + continue + for block in content: + if not isinstance(block, dict): + continue + # Truncate tool_result blocks + if block.get("type") == "tool_result": + result_str = block.get("content", "") + if isinstance(result_str, str) and len(result_str) > AGGRESSIVE_LIMIT: + block["content"] = ( + result_str[:AGGRESSIVE_LIMIT] + + f"\n\n[Truncated for context recovery: " + f"{len(result_str)} -> {AGGRESSIVE_LIMIT} chars]" + ) + truncated += 1 + # Truncate tool_use input blocks (e.g. large write content) + if block.get("type") == "tool_use" and isinstance(block.get("input"), dict): + input_str = json.dumps(block["input"], ensure_ascii=False) + if len(input_str) > AGGRESSIVE_LIMIT: + # Keep only a summary of the input + for key, val in block["input"].items(): + if isinstance(val, str) and len(val) > 1000: + block["input"][key] = ( + val[:1000] + + f"... [truncated {len(val)} chars]" + ) + truncated += 1 + + # Step 2: Truncate overly long user text messages (e.g. pasted content) + USER_MSG_LIMIT = 10000 + for msg in self.messages: + if msg.get("role") != "user": + continue + content = msg.get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + text = block.get("text", "") + if len(text) > USER_MSG_LIMIT: + block["text"] = ( + text[:USER_MSG_LIMIT] + + f"\n\n[Message truncated for context recovery: " + f"{len(text)} -> {USER_MSG_LIMIT} chars]" + ) + truncated += 1 + elif isinstance(content, str) and len(content) > USER_MSG_LIMIT: + msg["content"] = ( + content[:USER_MSG_LIMIT] + + f"\n\n[Message truncated for context recovery: " + f"{len(content)} -> {USER_MSG_LIMIT} chars]" + ) + truncated += 1 + + # Step 3: Keep only the last 5 complete turns + turns = self._identify_complete_turns() + if len(turns) > 5: + kept_turns = turns[-5:] + new_messages = [] + for turn in kept_turns: + new_messages.extend(turn["messages"]) + removed = len(turns) - 5 + self.messages[:] = new_messages + logger.info( + f"🔧 Aggressive trim: removed {removed} old turns, " + f"truncated {truncated} large blocks, " + f"{original_count} -> {len(self.messages)} messages" + ) + return True + + if truncated > 0: + logger.info( + f"🔧 Aggressive trim: truncated {truncated} large blocks " + f"(no turns removed, only {len(turns)} turn(s) left)" + ) + return True + + # Nothing left to trim + logger.warning("🔧 Aggressive trim: nothing to trim, will clear history") + return False + + def _build_context_summary_callback(self, discarded_turns: list, kept_turns: list): + """ + Build a callback that injects an LLM summary into the first user + message of *kept_turns*. Returns None if no valid injection target. + + The callback is passed to flush_from_messages so that the same LLM + call that writes daily memory also provides the in-context summary. + """ + if not kept_turns: + return None + + # Find the first user text block in kept_turns as injection target + target_block = None + for turn in kept_turns: + for msg in turn["messages"]: + if msg.get("role") == "user": + content = msg.get("content", []) + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + target_block = block + break + if target_block: + break + if target_block: + break + + if not target_block: + return None + + turn_count = len(discarded_turns) + original_text = target_block["text"] + + def _on_summary_ready(summary: str): + if not summary or not summary.strip(): + return + target_block["text"] = ( + f"[System: Previous conversation summary — " + f"{turn_count} turns were compacted]\n\n" + f"{summary.strip()}\n\n" + f"The recent conversation continues below.\n\n---\n\n" + f"{original_text}" + ) + logger.info( + f"📝 Context summary injected " + f"({len(summary)} chars, {turn_count} turns)" + ) + + return _on_summary_ready + + def _trim_messages(self): + """ + 智能清理消息历史,保持对话完整性 + + 使用完整轮次作为清理单位,确保: + 1. 不会在对话中间截断 + 2. 工具调用链(tool_use + tool_result)保持完整 + 3. 每轮对话都是完整的(用户消息 + AI回复 + 工具调用) + """ + if not self.messages or not self.agent: + return + + # Step 0: Truncate large tool results in historical turns (30K -> 10K) + self._truncate_historical_tool_results() + + # Step 1: 识别完整轮次 + turns = self._identify_complete_turns() + + if not turns: + return + + # Step 2: 轮次限制 - 超出时移除前一半,保留后一半 + if len(turns) > self.max_context_turns: + removed_count = len(turns) // 2 + keep_count = len(turns) - removed_count + + discarded_turns = turns[:removed_count] + turns = turns[-keep_count:] + + logger.info( + f"💾 Context turns exceeded: {keep_count + removed_count} > {self.max_context_turns}, " + f"trimmed to {keep_count} turns (removed {removed_count})" + ) + + # Flush to daily memory + inject context summary (single async LLM call) + if self.agent.memory_manager: + discarded_messages = [] + for turn in discarded_turns: + discarded_messages.extend(turn["messages"]) + if discarded_messages: + user_id = getattr(self.agent, '_current_user_id', None) + cb = self._build_context_summary_callback(discarded_turns, turns) + self.agent.memory_manager.flush_memory( + messages=discarded_messages, user_id=user_id, + reason="trim", max_messages=0, + context_summary_callback=cb, + ) + + # Step 3: Token 限制 - 保留完整轮次 + # Get context window from agent (based on model) + context_window = self.agent._get_model_context_window() + + # Use configured max_context_tokens if available + if hasattr(self.agent, 'max_context_tokens') and self.agent.max_context_tokens: + max_tokens = self.agent.max_context_tokens + else: + # Reserve 10% for response generation + reserve_tokens = int(context_window * 0.1) + max_tokens = context_window - reserve_tokens + + # Estimate system prompt tokens + system_tokens = self.agent._estimate_message_tokens({"role": "system", "content": self.system_prompt}) + available_tokens = max_tokens - system_tokens + + # Calculate current tokens + current_tokens = sum(self._estimate_turn_tokens(turn) for turn in turns) + + # If under limit, reconstruct messages and return + if current_tokens + system_tokens <= max_tokens: + # Reconstruct message list from turns + new_messages = [] + for turn in turns: + new_messages.extend(turn['messages']) + + old_count = len(self.messages) + self.messages = new_messages + + # Log if we removed messages due to turn limit + if old_count > len(self.messages): + logger.info(f" Rebuilt message list: {old_count} -> {len(self.messages)} messages") + return + + # Token limit exceeded — tiered strategy based on turn count: + # + # Few turns (<5): Compress ALL turns to text-only (strip tool chains, + # keep user query + final reply). Never discard turns + # — losing even one is too painful when context is thin. + # + # Many turns (>=5): Directly discard the first half of turns. + # With enough turns the oldest ones are less + # critical, and keeping the recent half intact + # (with full tool chains) is more useful. + + COMPRESS_THRESHOLD = 5 + + if len(turns) < COMPRESS_THRESHOLD: + # --- Few turns: compress ALL turns to text-only, never discard --- + compressed_turns = [] + for t in turns: + compressed = compress_turn_to_text_only(t) + if compressed["messages"]: + compressed_turns.append(compressed) + + new_messages = [] + for turn in compressed_turns: + new_messages.extend(turn["messages"]) + + new_tokens = sum(self._estimate_turn_tokens(t) for t in compressed_turns) + old_count = len(self.messages) + self.messages = new_messages + + logger.info( + f"📦 Context tokens exceeded (turns<{COMPRESS_THRESHOLD}): " + f"~{current_tokens + system_tokens} > {max_tokens}, " + f"compressed all {len(turns)} turns to plain text " + f"({old_count} -> {len(self.messages)} messages, " + f"~{current_tokens + system_tokens} -> ~{new_tokens + system_tokens} tokens)" + ) + return + + # --- Many turns (>=5): discard the older half, keep the newer half --- + removed_count = len(turns) // 2 + keep_count = len(turns) - removed_count + discarded_turns = turns[:removed_count] + kept_turns = turns[-keep_count:] + kept_tokens = sum(self._estimate_turn_tokens(t) for t in kept_turns) + + logger.info( + f"🔄 Context tokens exceeded: ~{current_tokens + system_tokens} > {max_tokens}, " + f"trimmed to {keep_count} turns (removed {removed_count})" + ) + + if self.agent.memory_manager: + discarded_messages = [] + for turn in discarded_turns: + discarded_messages.extend(turn["messages"]) + if discarded_messages: + user_id = getattr(self.agent, '_current_user_id', None) + cb = self._build_context_summary_callback(discarded_turns, kept_turns) + self.agent.memory_manager.flush_memory( + messages=discarded_messages, user_id=user_id, + reason="trim", max_messages=0, + context_summary_callback=cb, + ) + + new_messages = [] + for turn in kept_turns: + new_messages.extend(turn['messages']) + + old_count = len(self.messages) + self.messages = new_messages + + logger.info( + f" Removed {removed_count} turns " + f"({old_count} -> {len(self.messages)} messages, " + f"~{current_tokens + system_tokens} -> ~{kept_tokens + system_tokens} tokens)" + ) + + def _clear_session_db(self): + """ + Clear the current session's persisted messages from SQLite DB. + + This prevents dirty data (broken tool_use/tool_result pairs) from being + reloaded on the next request or after a restart. + """ + try: + session_id = getattr(self.agent, '_current_session_id', None) + if not session_id: + return + from agent.memory import get_conversation_store + store = get_conversation_store() + store.clear_session(session_id) + logger.info(f"🗑️ Cleared dirty session data from DB: {session_id}") + except Exception as e: + logger.warning(f"Failed to clear session DB: {e}") + + def _prepare_messages(self) -> List[Dict[str, Any]]: + """ + Prepare messages to send to LLM + + Note: For Claude API, system prompt should be passed separately via system parameter, + not as a message. The AgentLLMModel will handle this. + """ + # Don't add system message here - it will be handled separately by the LLM adapter + return self.messages diff --git a/agent/protocol/cancel.py b/agent/protocol/cancel.py new file mode 100644 index 0000000..6354cd3 --- /dev/null +++ b/agent/protocol/cancel.py @@ -0,0 +1,121 @@ +""" +Cancel token registry for aborting in-flight agent runs. + +A user cancel (web Cancel button, /cancel command) sets a threading.Event +that the agent loop polls at safe checkpoints. Tokens are keyed by +request_id (preferred) and tracked under session_id as a fallback. Entries +are released after the run completes to keep the registry bounded. + +No project deps — importable from any layer without circular imports. +""" + +from __future__ import annotations + +import threading +from typing import Dict, Optional + + +class AgentCancelledError(Exception): + """Raised inside the agent loop when a stop has been requested. + + The agent stream executor catches this, injects a "[Interrupted]" note + into the message history (preserving tool_use/tool_result integrity) + and returns a partial response to the caller. + """ + + +class _CancelEntry: + __slots__ = ("event", "session_id") + + def __init__(self, session_id: Optional[str]): + self.event = threading.Event() + self.session_id = session_id + + +class CancelTokenRegistry: + """In-process registry mapping request_id -> cancel Event. + + Thread-safe. Singleton via module-level ``_registry``. + """ + + def __init__(self): + self._lock = threading.Lock() + self._by_request: Dict[str, _CancelEntry] = {} + # session_id -> set of request_ids currently in flight (usually 1). + self._by_session: Dict[str, set] = {} + + def register(self, request_id: str, session_id: Optional[str] = None) -> threading.Event: + """Create (or return existing) cancel event for a request. + + Returns the threading.Event the caller should poll via ``is_set()``. + """ + if not request_id: + return threading.Event() + with self._lock: + entry = self._by_request.get(request_id) + if entry is None: + entry = _CancelEntry(session_id) + self._by_request[request_id] = entry + if session_id: + self._by_session.setdefault(session_id, set()).add(request_id) + return entry.event + + def get_event(self, request_id: str) -> Optional[threading.Event]: + if not request_id: + return None + with self._lock: + entry = self._by_request.get(request_id) + return entry.event if entry else None + + def cancel_request(self, request_id: str) -> bool: + """Trigger cancel for a specific request. Returns True when matched.""" + if not request_id: + return False + with self._lock: + entry = self._by_request.get(request_id) + if entry is None: + return False + entry.event.set() + return True + + def cancel_session(self, session_id: str) -> int: + """Trigger cancel for every in-flight request of a session. + + Returns the number of requests cancelled (0 when nothing was running). + """ + if not session_id: + return 0 + with self._lock: + request_ids = list(self._by_session.get(session_id, ())) + entries = [self._by_request[r] for r in request_ids if r in self._by_request] + for entry in entries: + entry.event.set() + return len(entries) + + def unregister(self, request_id: str) -> None: + """Remove an entry once the agent run is done. Safe to call twice.""" + if not request_id: + return + with self._lock: + entry = self._by_request.pop(request_id, None) + if entry and entry.session_id: + bucket = self._by_session.get(entry.session_id) + if bucket is not None: + bucket.discard(request_id) + if not bucket: + self._by_session.pop(entry.session_id, None) + + def has_active(self, session_id: str) -> bool: + if not session_id: + return False + with self._lock: + bucket = self._by_session.get(session_id) + return bool(bucket) + + +_registry = CancelTokenRegistry() + + +def get_cancel_registry() -> CancelTokenRegistry: + """Module-level accessor for the singleton registry.""" + return _registry diff --git a/agent/protocol/context.py b/agent/protocol/context.py new file mode 100644 index 0000000..1c37850 --- /dev/null +++ b/agent/protocol/context.py @@ -0,0 +1,27 @@ +class TeamContext: + def __init__(self, name: str, description: str, rule: str, agents: list, max_steps: int = 100): + """ + Initialize the TeamContext with a name, description, rules, a list of agents, and a user question. + :param name: The name of the group context. + :param description: A description of the group context. + :param rule: The rules governing the group context. + :param agents: A list of agents in the context. + """ + self.name = name + self.description = description + self.rule = rule + self.agents = agents + self.user_task = "" # For backward compatibility + self.task = None # Will be a Task instance + self.model = None # Will be an instance of LLMModel + self.task_short_name = None # Store the task directory name + # List of agents that have been executed + self.agent_outputs: list = [] + self.current_steps = 0 + self.max_steps = max_steps + + +class AgentOutput: + def __init__(self, agent_name: str, output: str): + self.agent_name = agent_name + self.output = output \ No newline at end of file diff --git a/agent/protocol/message_utils.py b/agent/protocol/message_utils.py new file mode 100644 index 0000000..160ad59 --- /dev/null +++ b/agent/protocol/message_utils.py @@ -0,0 +1,335 @@ +""" +Message sanitizer — fix broken tool_use / tool_result pairs. + +Provides two public helpers that can be reused across agent_stream.py +and any bot that converts messages to OpenAI format: + +1. sanitize_claude_messages(messages) + Operates on the internal Claude-format message list (in-place). + +2. drop_orphaned_tool_results_openai(messages) + Operates on an already-converted OpenAI-format message list, + returning a cleaned copy. +""" + +from __future__ import annotations + +from typing import Dict, List, Set + +from common.log import logger + +_SYNTH_TOOL_ERR = ( + "Error: Missing tool_result adjacent to tool_use (session repair). " + "The conversation history was inconsistent; continue from here." +) + + +def _repair_tool_use_adjacency(messages: List[Dict]) -> int: + """ + Anthropic requires: after assistant content with tool_use, the next message + must be user content listing tool_result for every tool_use id (same user msg). + + Valid histories satisfy this at every such assistant; the loop only mutates + when that condition fails (broken persistence, bad trims, etc.). + """ + + def _synth_block(tid: str) -> Dict: + return { + "type": "tool_result", + "tool_use_id": tid, + "content": _SYNTH_TOOL_ERR, + "is_error": True, + } + + repairs = 0 + i = 0 + while i < len(messages): + msg = messages[i] + if msg.get("role") != "assistant": + i += 1 + continue + + content = msg.get("content", []) + if not isinstance(content, list): + i += 1 + continue + + required = [ + b.get("id") + for b in content + if isinstance(b, dict) and b.get("type") == "tool_use" and b.get("id") + ] + if not required: + i += 1 + continue + + req_set = set(required) + if i + 1 >= len(messages): + messages.append({ + "role": "user", + "content": [_synth_block(tid) for tid in required], + }) + logger.warning( + "⚠️ Appended synthetic tool_result after trailing assistant tool_use" + ) + repairs += 1 + break + + nxt = messages[i + 1] + if nxt.get("role") != "user": + messages.insert( + i + 1, + {"role": "user", "content": [_synth_block(tid) for tid in required]}, + ) + logger.warning( + "⚠️ Inserted synthetic tool_result user after tool_use " + f"(next role={nxt.get('role')!r})" + ) + repairs += 1 + i += 2 + continue + + nc = nxt.get("content", []) + if not isinstance(nc, list): + messages.insert( + i + 1, + {"role": "user", "content": [_synth_block(tid) for tid in required]}, + ) + repairs += 1 + i += 2 + continue + + present = { + b.get("tool_use_id") + for b in nc + if isinstance(b, dict) and b.get("type") == "tool_result" and b.get("tool_use_id") + } + if req_set <= present: + i += 1 + continue + + missing = [tid for tid in required if tid not in present] + nxt["content"] = [_synth_block(tid) for tid in missing] + nc + logger.warning( + "⚠️ Prepended synthetic tool_result for Anthropic adjacency " + f"(missing_ids={missing})" + ) + repairs += len(missing) + i += 1 + + return repairs + + +# ------------------------------------------------------------------ # +# Claude-format sanitizer (used by agent_stream) +# ------------------------------------------------------------------ # + +def sanitize_claude_messages(messages: List[Dict]) -> int: + """ + Validate and fix a Claude-format message list **in-place**. + + Fixes handled: + - Anthropic adjacency: assistant tool_use must be immediately followed by + user message(s) containing matching tool_result blocks + - Leading orphaned tool_result user messages + - Mid-list tool_result blocks whose tool_use_id has no matching + tool_use in any preceding assistant message + + Returns: number of removals plus adjacency repair operations (inserts/prepends). + """ + if not messages: + return 0 + + removed = 0 + + # 1. Adjacency repair (Anthropic: tool_result must be in the next user message) + adj_repairs = _repair_tool_use_adjacency(messages) + + # 2. Remove leading orphaned tool_result user messages + while messages: + first = messages[0] + if first.get("role") != "user": + break + content = first.get("content", []) + if isinstance(content, list) and _has_block_type(content, "tool_result") \ + and not _has_block_type(content, "text"): + logger.warning("⚠️ Removing leading orphaned tool_result user message") + messages.pop(0) + removed += 1 + else: + break + + # 3. Iteratively remove unmatched tool_use / tool_result until stable. + # Removing one broken message can orphan others (e.g. an assistant msg + # with both matched and unmatched tool_use — deleting it orphans the + # previously-matched tool_result). Loop until clean. + for _ in range(5): + use_ids: Set[str] = set() + result_ids: Set[str] = set() + for msg in messages: + for block in (msg.get("content") or []): + if not isinstance(block, dict): + continue + if block.get("type") == "tool_use" and block.get("id"): + use_ids.add(block["id"]) + elif block.get("type") == "tool_result" and block.get("tool_use_id"): + result_ids.add(block["tool_use_id"]) + + bad_use = use_ids - result_ids + bad_result = result_ids - use_ids + if not bad_use and not bad_result: + break + + pass_removed = 0 + i = 0 + while i < len(messages): + msg = messages[i] + role = msg.get("role") + content = msg.get("content", []) + if not isinstance(content, list): + i += 1 + continue + + if role == "assistant" and bad_use and any( + isinstance(b, dict) and b.get("type") == "tool_use" + and b.get("id") in bad_use for b in content + ): + logger.warning(f"⚠️ Removing assistant msg with unmatched tool_use") + messages.pop(i) + pass_removed += 1 + continue + + if role == "user" and bad_result and _has_block_type(content, "tool_result"): + has_bad = any( + isinstance(b, dict) and b.get("type") == "tool_result" + and b.get("tool_use_id") in bad_result for b in content + ) + if has_bad: + if not _has_block_type(content, "text"): + logger.warning(f"⚠️ Removing user msg with unmatched tool_result") + messages.pop(i) + pass_removed += 1 + continue + else: + before = len(content) + msg["content"] = [ + b for b in content + if not (isinstance(b, dict) and b.get("type") == "tool_result" + and b.get("tool_use_id") in bad_result) + ] + pass_removed += before - len(msg["content"]) + + i += 1 + + removed += pass_removed + if pass_removed == 0: + break + + # 4. Removals above can break adjacency; re-run repair only if something was removed. + if removed: + adj_repairs += _repair_tool_use_adjacency(messages) + + if removed: + logger.info(f"🔧 Message validation: removed {removed} broken message(s)") + if adj_repairs: + logger.info(f"🔧 Message validation: adjacency repairs={adj_repairs}") + return removed + adj_repairs + + +# ------------------------------------------------------------------ # +# OpenAI-format sanitizer (used by minimax_bot, openai_compatible_bot) +# ------------------------------------------------------------------ # + +def drop_orphaned_tool_results_openai(messages: List[Dict]) -> List[Dict]: + """ + Return a copy of *messages* (OpenAI format) with any ``role=tool`` + messages removed if their ``tool_call_id`` does not match a + ``tool_calls[].id`` in a preceding assistant message. + """ + known_ids: Set[str] = set() + cleaned: List[Dict] = [] + for msg in messages: + if msg.get("role") == "assistant" and msg.get("tool_calls"): + for tc in msg["tool_calls"]: + tc_id = tc.get("id", "") + if tc_id: + known_ids.add(tc_id) + + if msg.get("role") == "tool": + ref_id = msg.get("tool_call_id", "") + if ref_id and ref_id not in known_ids: + logger.warning( + f"[MessageSanitizer] Dropping orphaned tool result " + f"(tool_call_id={ref_id} not in known ids)" + ) + continue + cleaned.append(msg) + return cleaned + + +# ------------------------------------------------------------------ # +# Internal helpers +# ------------------------------------------------------------------ # + +def _has_block_type(content: list, block_type: str) -> bool: + return any( + isinstance(b, dict) and b.get("type") == block_type + for b in content + ) + + +def _extract_text_from_content(content) -> str: + """Extract plain text from a message content field (str or list of blocks).""" + if isinstance(content, str): + return content.strip() + if isinstance(content, list): + parts = [ + b.get("text", "") + for b in content + if isinstance(b, dict) and b.get("type") == "text" + ] + return "\n".join(p for p in parts if p).strip() + return "" + + +def compress_turn_to_text_only(turn: Dict) -> Dict: + """ + Compress a full turn (with tool_use/tool_result chains) into a lightweight + text-only turn that keeps only the first user text and the last assistant text. + + This preserves the conversational context (what the user asked and what the + agent concluded) while stripping out the bulky intermediate tool interactions. + + Returns a new turn dict with a ``messages`` list; the original is not mutated. + """ + user_text = "" + last_assistant_text = "" + + for msg in turn["messages"]: + role = msg.get("role") + content = msg.get("content", []) + + if role == "user": + if isinstance(content, list) and _has_block_type(content, "tool_result"): + continue + if not user_text: + user_text = _extract_text_from_content(content) + + elif role == "assistant": + text = _extract_text_from_content(content) + if text: + last_assistant_text = text + + compressed_messages = [] + if user_text: + compressed_messages.append({ + "role": "user", + "content": [{"type": "text", "text": user_text}] + }) + if last_assistant_text: + compressed_messages.append({ + "role": "assistant", + "content": [{"type": "text", "text": last_assistant_text}] + }) + + return {"messages": compressed_messages} diff --git a/agent/protocol/models.py b/agent/protocol/models.py new file mode 100644 index 0000000..9157211 --- /dev/null +++ b/agent/protocol/models.py @@ -0,0 +1,57 @@ +""" +Models module for agent system. +Provides basic model classes needed by tools and bridge integration. +""" + +from typing import Any, Dict, List, Optional + + +class LLMRequest: + """Request model for LLM operations""" + + def __init__(self, messages: List[Dict[str, str]] = None, model: Optional[str] = None, + temperature: float = 0.7, max_tokens: Optional[int] = None, + stream: bool = False, tools: Optional[List] = None, **kwargs): + self.messages = messages or [] + self.model = model + self.temperature = temperature + self.max_tokens = max_tokens + self.stream = stream + self.tools = tools + # Allow extra attributes + for key, value in kwargs.items(): + setattr(self, key, value) + + +class LLMModel: + """Base class for LLM models""" + + def __init__(self, model: str = None, **kwargs): + self.model = model + self.config = kwargs + + def call(self, request: LLMRequest): + """ + Call the model with a request. + This is a placeholder implementation. + """ + raise NotImplementedError("LLMModel.call not implemented in this context") + + def call_stream(self, request: LLMRequest): + """ + Call the model with streaming. + This is a placeholder implementation. + """ + raise NotImplementedError("LLMModel.call_stream not implemented in this context") + + +class ModelFactory: + """Factory for creating model instances""" + + @staticmethod + def create_model(model_type: str, **kwargs): + """ + Create a model instance based on type. + This is a placeholder implementation. + """ + raise NotImplementedError("ModelFactory.create_model not implemented in this context") \ No newline at end of file diff --git a/agent/protocol/result.py b/agent/protocol/result.py new file mode 100644 index 0000000..ffbe44c --- /dev/null +++ b/agent/protocol/result.py @@ -0,0 +1,97 @@ +from __future__ import annotations +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import List, Dict, Any, Optional + +from agent.protocol.task import Task, TaskStatus + + +class AgentActionType(Enum): + """Enum representing different types of agent actions.""" + TOOL_USE = "tool_use" + THINKING = "thinking" + FINAL_ANSWER = "final_answer" + + +@dataclass +class ToolResult: + """ + Represents the result of a tool use. + + Attributes: + tool_name: Name of the tool used + input_params: Parameters passed to the tool + output: Output from the tool + status: Status of the tool execution (success/error) + error_message: Error message if the tool execution failed + execution_time: Time taken to execute the tool + """ + tool_name: str + input_params: Dict[str, Any] + output: Any + status: str + error_message: Optional[str] = None + execution_time: float = 0.0 + + +@dataclass +class AgentAction: + """ + Represents an action taken by an agent. + + Attributes: + id: Unique identifier for the action + agent_id: ID of the agent that performed the action + agent_name: Name of the agent that performed the action + action_type: Type of action (tool use, thinking, final answer) + content: Content of the action (thought content, final answer content) + tool_result: Tool use details if action_type is TOOL_USE + timestamp: When the action was performed + """ + agent_id: str + agent_name: str + action_type: AgentActionType + id: str = field(default_factory=lambda: str(uuid.uuid4())) + content: str = "" + tool_result: Optional[ToolResult] = None + thought: Optional[str] = None + timestamp: float = field(default_factory=time.time) + + +@dataclass +class AgentResult: + """ + Represents the result of an agent's execution. + + Attributes: + final_answer: The final answer provided by the agent + step_count: Number of steps taken by the agent + status: Status of the execution (success/error) + error_message: Error message if execution failed + """ + final_answer: str + step_count: int + status: str = "success" + error_message: Optional[str] = None + + @classmethod + def success(cls, final_answer: str, step_count: int) -> "AgentResult": + """Create a successful result""" + return cls(final_answer=final_answer, step_count=step_count) + + @classmethod + def error(cls, error_message: str, step_count: int = 0) -> "AgentResult": + """Create an error result""" + return cls( + final_answer=f"Error: {error_message}", + step_count=step_count, + status="error", + error_message=error_message + ) + + @property + def is_error(self) -> bool: + """Check if the result represents an error""" + return self.status == "error" \ No newline at end of file diff --git a/agent/protocol/task.py b/agent/protocol/task.py new file mode 100644 index 0000000..53a703f --- /dev/null +++ b/agent/protocol/task.py @@ -0,0 +1,96 @@ +from __future__ import annotations +import time +import uuid +from dataclasses import dataclass, field +from enum import Enum +from typing import Dict, Any, List + + +class TaskType(Enum): + """Enum representing different types of tasks.""" + TEXT = "text" + IMAGE = "image" + VIDEO = "video" + AUDIO = "audio" + FILE = "file" + MIXED = "mixed" + + +class TaskStatus(Enum): + """Enum representing the status of a task.""" + INIT = "init" # Initial state + PROCESSING = "processing" # In progress + COMPLETED = "completed" # Completed + FAILED = "failed" # Failed + + +@dataclass +class Task: + """ + Represents a task to be processed by an agent. + + Attributes: + id: Unique identifier for the task + content: The primary text content of the task + type: Type of the task + status: Current status of the task + created_at: Timestamp when the task was created + updated_at: Timestamp when the task was last updated + metadata: Additional metadata for the task + images: List of image URLs or base64 encoded images + videos: List of video URLs + audios: List of audio URLs or base64 encoded audios + files: List of file URLs or paths + """ + id: str = field(default_factory=lambda: str(uuid.uuid4())) + content: str = "" + type: TaskType = TaskType.TEXT + status: TaskStatus = TaskStatus.INIT + created_at: float = field(default_factory=time.time) + updated_at: float = field(default_factory=time.time) + metadata: Dict[str, Any] = field(default_factory=dict) + + # Media content + images: List[str] = field(default_factory=list) + videos: List[str] = field(default_factory=list) + audios: List[str] = field(default_factory=list) + files: List[str] = field(default_factory=list) + + def __init__(self, content: str = "", **kwargs): + """ + Initialize a Task with content and optional keyword arguments. + + Args: + content: The text content of the task + **kwargs: Additional attributes to set + """ + self.id = kwargs.get('id', str(uuid.uuid4())) + self.content = content + self.type = kwargs.get('type', TaskType.TEXT) + self.status = kwargs.get('status', TaskStatus.INIT) + self.created_at = kwargs.get('created_at', time.time()) + self.updated_at = kwargs.get('updated_at', time.time()) + self.metadata = kwargs.get('metadata', {}) + self.images = kwargs.get('images', []) + self.videos = kwargs.get('videos', []) + self.audios = kwargs.get('audios', []) + self.files = kwargs.get('files', []) + + def get_text(self) -> str: + """ + Get the text content of the task. + + Returns: + The text content + """ + return self.content + + def update_status(self, status: TaskStatus) -> None: + """ + Update the status of the task. + + Args: + status: The new status + """ + self.status = status + self.updated_at = time.time() \ No newline at end of file diff --git a/agent/skills/__init__.py b/agent/skills/__init__.py new file mode 100644 index 0000000..c634579 --- /dev/null +++ b/agent/skills/__init__.py @@ -0,0 +1,31 @@ +""" +Skills module for agent system. + +This module provides the framework for loading, managing, and executing skills. +Skills are markdown files with frontmatter that provide specialized instructions +for specific tasks. +""" + +from agent.skills.types import ( + Skill, + SkillEntry, + SkillMetadata, + SkillInstallSpec, + LoadSkillsResult, +) +from agent.skills.loader import SkillLoader +from agent.skills.manager import SkillManager +from agent.skills.service import SkillService +from agent.skills.formatter import format_skills_for_prompt + +__all__ = [ + "Skill", + "SkillEntry", + "SkillMetadata", + "SkillInstallSpec", + "LoadSkillsResult", + "SkillLoader", + "SkillManager", + "SkillService", + "format_skills_for_prompt", +] diff --git a/agent/skills/config.py b/agent/skills/config.py new file mode 100644 index 0000000..788009f --- /dev/null +++ b/agent/skills/config.py @@ -0,0 +1,230 @@ +""" +Configuration support for skills. +""" + +import os +import platform +from typing import Dict, Optional, List +from agent.skills.types import SkillEntry + + +def resolve_runtime_platform() -> str: + """Get the current runtime platform.""" + return platform.system().lower() + + +def has_binary(bin_name: str) -> bool: + """ + Check if a binary is available in PATH. + + :param bin_name: Binary name to check + :return: True if binary is available + """ + import shutil + return shutil.which(bin_name) is not None + + +def has_any_binary(bin_names: List[str]) -> bool: + """ + Check if any of the given binaries is available. + + :param bin_names: List of binary names to check + :return: True if at least one binary is available + """ + return any(has_binary(bin_name) for bin_name in bin_names) + + +def has_env_var(env_name: str) -> bool: + """ + Check if an environment variable is set. + + :param env_name: Environment variable name + :return: True if environment variable is set + """ + return env_name in os.environ and bool(os.environ[env_name].strip()) + + +def get_skill_config(config: Optional[Dict], skill_name: str) -> Optional[Dict]: + """ + Get skill-specific configuration. + + :param config: Global configuration dictionary + :param skill_name: Name of the skill + :return: Skill configuration or None + """ + if not config: + return None + + skills_config = config.get('skills', {}) + if not isinstance(skills_config, dict): + return None + + entries = skills_config.get('entries', {}) + if not isinstance(entries, dict): + return None + + return entries.get(skill_name) + + +def should_include_skill( + entry: SkillEntry, + config: Optional[Dict] = None, + current_platform: Optional[str] = None, +) -> bool: + """ + Determine if a skill should be included based on requirements. + + Simple rule: Skills are auto-enabled if their requirements are met. + - Has required API keys → enabled + - Missing API keys → disabled + - Wrong keys → enabled but will fail at runtime (LLM will handle error) + + :param entry: SkillEntry to check + :param config: Configuration dictionary (currently unused, reserved for future) + :param current_platform: Current platform (default: auto-detect) + :return: True if skill should be included + """ + metadata = entry.metadata + + # No metadata = always include (no requirements) + if not metadata: + return True + + # Check platform requirements (can't work on wrong platform) + if metadata.os: + platform_name = current_platform or resolve_runtime_platform() + # Map common platform names + platform_map = { + 'darwin': 'darwin', + 'linux': 'linux', + 'windows': 'win32', + } + normalized_platform = platform_map.get(platform_name, platform_name) + + if normalized_platform not in metadata.os: + return False + + # If skill has 'always: true', include it regardless of other requirements + if metadata.always: + return True + + # Check requirements + if metadata.requires: + # Check required binaries (all must be present) + required_bins = metadata.requires.get('bins', []) + if required_bins: + if not all(has_binary(bin_name) for bin_name in required_bins): + return False + + # Check anyBins (at least one must be present) + any_bins = metadata.requires.get('anyBins', []) + if any_bins: + if not has_any_binary(any_bins): + return False + + # Check environment variables (API keys) + # All required env vars must be set + required_env = metadata.requires.get('env', []) + if required_env: + for env_name in required_env: + if not has_env_var(env_name): + return False + + # Check anyEnv (at least one must be present) + any_env = metadata.requires.get('anyEnv', []) + if any_env: + if not any(has_env_var(e) for e in any_env): + return False + + return True + + +def get_missing_requirements( + entry: SkillEntry, + current_platform: Optional[str] = None, +) -> Dict[str, List[str]]: + """ + Return a dict of missing requirements for a skill. + Empty dict means all requirements are met. + + :param entry: SkillEntry to check + :param current_platform: Current platform (default: auto-detect) + :return: Dict like {"bins": ["curl"], "env": ["API_KEY"]} + """ + missing: Dict[str, List[str]] = {} + metadata = entry.metadata + + if not metadata or not metadata.requires: + return missing + + required_bins = metadata.requires.get('bins', []) + if required_bins: + missing_bins = [b for b in required_bins if not has_binary(b)] + if missing_bins: + missing['bins'] = missing_bins + + any_bins = metadata.requires.get('anyBins', []) + if any_bins and not has_any_binary(any_bins): + missing['anyBins'] = any_bins + + required_env = metadata.requires.get('env', []) + if required_env: + missing_env = [e for e in required_env if not has_env_var(e)] + if missing_env: + missing['env'] = missing_env + + any_env = metadata.requires.get('anyEnv', []) + if any_env and not any(has_env_var(e) for e in any_env): + missing['anyEnv'] = any_env + + return missing + + +def is_config_path_truthy(config: Dict, path: str) -> bool: + """ + Check if a config path resolves to a truthy value. + + :param config: Configuration dictionary + :param path: Dot-separated path (e.g., 'skills.enabled') + :return: True if path resolves to truthy value + """ + parts = path.split('.') + current = config + + for part in parts: + if not isinstance(current, dict): + return False + current = current.get(part) + if current is None: + return False + + # Check if value is truthy + if isinstance(current, bool): + return current + if isinstance(current, (int, float)): + return current != 0 + if isinstance(current, str): + return bool(current.strip()) + + return bool(current) + + +def resolve_config_path(config: Dict, path: str): + """ + Resolve a dot-separated config path to its value. + + :param config: Configuration dictionary + :param path: Dot-separated path + :return: Value at path or None + """ + parts = path.split('.') + current = config + + for part in parts: + if not isinstance(current, dict): + return None + current = current.get(part) + if current is None: + return None + + return current diff --git a/agent/skills/formatter.py b/agent/skills/formatter.py new file mode 100644 index 0000000..d1eebe0 --- /dev/null +++ b/agent/skills/formatter.py @@ -0,0 +1,126 @@ +""" +Skill formatter for generating prompts from skills. +""" + +from typing import Dict, List +from agent.skills.types import Skill, SkillEntry + + +def format_skills_for_prompt(skills: List[Skill]) -> str: + """ + Format skills for inclusion in a system prompt. + + Uses XML format per Agent Skills standard. + Skills with disable_model_invocation=True are excluded. + + :param skills: List of skills to format + :return: Formatted prompt text + """ + # Filter out skills that should not be invoked by the model + visible_skills = [s for s in skills if not s.disable_model_invocation] + + if not visible_skills: + return "" + + lines = [ + "", + "", + ] + + for skill in visible_skills: + lines.append(" ") + lines.append(f" {_escape_xml(skill.name)}") + lines.append(f" {_escape_xml(skill.description)}") + lines.append(f" {_escape_xml(skill.file_path)}") + lines.append(f" {_escape_xml(skill.base_dir)}") + lines.append(" ") + + lines.append("") + + return "\n".join(lines) + + +def format_skill_entries_for_prompt(entries: List[SkillEntry]) -> str: + """ + Format skill entries for inclusion in a system prompt. + + :param entries: List of skill entries to format + :return: Formatted prompt text + """ + skills = [entry.skill for entry in entries] + return format_skills_for_prompt(skills) + + +def format_unavailable_skills_for_prompt( + entries: List[SkillEntry], + missing_map: Dict[str, Dict[str, List[str]]], +) -> str: + """ + Format unavailable (requires-not-met) skills as brief setup hints + so the AI can guide users to configure them. + + :param entries: List of unavailable skill entries + :param missing_map: Dict mapping skill name to its missing requirements + :return: Formatted prompt text + """ + if not entries: + return "" + + lines = [ + "", + "", + "The following skills are installed but not yet ready. " + "Guide the user to complete the setup when relevant.", + ] + + for entry in entries: + skill = entry.skill + missing = missing_map.get(skill.name, {}) + + missing_parts = [] + for key, values in missing.items(): + missing_parts.append(f"{key}: {', '.join(values)}") + missing_str = "; ".join(missing_parts) if missing_parts else "unknown" + + setup_hint = _extract_setup_hint(skill) + + lines.append(" ") + lines.append(f" {_escape_xml(skill.name)}") + lines.append(f" {_escape_xml(skill.description)}") + lines.append(f" {_escape_xml(missing_str)}") + if setup_hint: + lines.append(f" {_escape_xml(setup_hint)}") + lines.append(" ") + + lines.append("") + return "\n".join(lines) + + +def _extract_setup_hint(skill: Skill) -> str: + """ + Extract the Setup section from SKILL.md content as a brief hint. + Returns the first few lines of the ## Setup section. + """ + content = skill.content + if not content: + return "" + + import re + match = re.search(r'^##\s+Setup\s*\n(.*?)(?=\n##\s|\Z)', content, re.MULTILINE | re.DOTALL) + if not match: + return "" + + setup_text = match.group(1).strip() + lines = setup_text.split('\n') + hint_lines = [l.strip() for l in lines[:6] if l.strip()] + return ' '.join(hint_lines)[:300] + + +def _escape_xml(text: str) -> str: + """Escape XML special characters.""" + return (text + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') + .replace("'", ''')) diff --git a/agent/skills/frontmatter.py b/agent/skills/frontmatter.py new file mode 100644 index 0000000..83d09f8 --- /dev/null +++ b/agent/skills/frontmatter.py @@ -0,0 +1,192 @@ +""" +Frontmatter parsing for skills. +""" + +import re +import json +from typing import Dict, Any, Optional, List +from agent.skills.types import SkillMetadata, SkillInstallSpec + + +def parse_frontmatter(content: str) -> Dict[str, Any]: + """ + Parse YAML-style frontmatter from markdown content. + + Returns a dictionary of frontmatter fields. + """ + frontmatter = {} + + # Match frontmatter block between --- markers + match = re.match(r'^---\s*\n(.*?)\n---\s*\n', content, re.DOTALL) + if not match: + return frontmatter + + frontmatter_text = match.group(1) + + # Try to use PyYAML for proper YAML parsing + try: + import yaml + frontmatter = yaml.safe_load(frontmatter_text) + if not isinstance(frontmatter, dict): + frontmatter = {} + return frontmatter + except ImportError: + # Fallback to simple parsing if PyYAML not available + pass + except Exception: + # If YAML parsing fails, fall back to simple parsing + pass + + # Simple YAML-like parsing (supports key: value format only) + # This is a fallback for when PyYAML is not available + for line in frontmatter_text.split('\n'): + line = line.strip() + if not line or line.startswith('#'): + continue + + if ':' in line: + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + + # Try to parse as JSON if it looks like JSON + if value.startswith('{') or value.startswith('['): + try: + value = json.loads(value) + except json.JSONDecodeError: + pass + # Parse boolean values + elif value.lower() in ('true', 'false'): + value = value.lower() == 'true' + # Parse numbers + elif value.isdigit(): + value = int(value) + + frontmatter[key] = value + + return frontmatter + + +def parse_metadata(frontmatter: Dict[str, Any]) -> Optional[SkillMetadata]: + """ + Parse skill metadata from frontmatter. + + Looks for 'metadata' field containing JSON with skill configuration. + """ + metadata_raw = frontmatter.get('metadata') + if not metadata_raw: + return None + + # If it's a string, try to parse as JSON + if isinstance(metadata_raw, str): + try: + metadata_raw = json.loads(metadata_raw) + except json.JSONDecodeError: + return None + + if not isinstance(metadata_raw, dict): + return None + + # Unwrap nested namespace (e.g. {"openclaw": {...}} or {"cowagent": {...}}) + meta_obj = _unwrap_metadata_namespace(metadata_raw) + + # Parse install specs + install_specs = [] + install_raw = meta_obj.get('install', []) + if isinstance(install_raw, list): + for spec_raw in install_raw: + if not isinstance(spec_raw, dict): + continue + + kind = spec_raw.get('kind', spec_raw.get('type', '')).lower() + if not kind: + continue + + spec = SkillInstallSpec( + kind=kind, + id=spec_raw.get('id'), + label=spec_raw.get('label'), + bins=_normalize_string_list(spec_raw.get('bins')), + os=_normalize_string_list(spec_raw.get('os')), + formula=spec_raw.get('formula'), + package=spec_raw.get('package'), + module=spec_raw.get('module'), + url=spec_raw.get('url'), + archive=spec_raw.get('archive'), + extract=spec_raw.get('extract', False), + strip_components=spec_raw.get('stripComponents'), + target_dir=spec_raw.get('targetDir'), + ) + install_specs.append(spec) + + # Parse requires + requires = {} + requires_raw = meta_obj.get('requires', {}) + if isinstance(requires_raw, dict): + for key, value in requires_raw.items(): + requires[key] = _normalize_string_list(value) + + return SkillMetadata( + always=meta_obj.get('always', False), + default_enabled=meta_obj.get('default_enabled', True), + skill_key=meta_obj.get('skillKey'), + primary_env=meta_obj.get('primaryEnv'), + emoji=meta_obj.get('emoji'), + homepage=meta_obj.get('homepage'), + os=_normalize_string_list(meta_obj.get('os')), + requires=requires, + install=install_specs, + ) + + +_KNOWN_METADATA_NAMESPACES = {"cowagent", "openclaw"} + + +def _unwrap_metadata_namespace(metadata_raw: Dict[str, Any]) -> Dict[str, Any]: + """ + Unwrap a single-key namespace wrapper like {"cowagent": {...} or {"openclaw": {...}}}. + If the top-level dict has exactly one key matching a known namespace, return the inner dict. + Otherwise return the original dict unchanged. + """ + keys = set(metadata_raw.keys()) + ns_keys = keys & _KNOWN_METADATA_NAMESPACES + if len(ns_keys) == 1 and len(keys) == 1: + ns = ns_keys.pop() + inner = metadata_raw[ns] + if isinstance(inner, dict): + return inner + return metadata_raw + + +def _normalize_string_list(value: Any) -> List[str]: + """Normalize a value to a list of strings.""" + if not value: + return [] + + if isinstance(value, list): + return [str(v).strip() for v in value if v] + + if isinstance(value, str): + return [v.strip() for v in value.split(',') if v.strip()] + + return [] + + +def parse_boolean_value(value: Optional[str], default: bool = False) -> bool: + """Parse a boolean value from frontmatter.""" + if value is None: + return default + + if isinstance(value, bool): + return value + + if isinstance(value, str): + return value.lower() in ('true', '1', 'yes', 'on') + + return default + + +def get_frontmatter_value(frontmatter: Dict[str, Any], key: str) -> Optional[str]: + """Get a frontmatter value as a string.""" + value = frontmatter.get(key) + return str(value) if value is not None else None diff --git a/agent/skills/loader.py b/agent/skills/loader.py new file mode 100644 index 0000000..3784b01 --- /dev/null +++ b/agent/skills/loader.py @@ -0,0 +1,286 @@ +""" +Skill loader for discovering and loading skills from directories. +""" + +import os +from pathlib import Path +from typing import List, Optional, Dict +from common.log import logger +from agent.skills.types import Skill, SkillEntry, LoadSkillsResult, SkillMetadata +from agent.skills.frontmatter import parse_frontmatter, parse_metadata, parse_boolean_value, get_frontmatter_value + + +class SkillLoader: + """Loads skills from various directories.""" + + def __init__(self): + pass + + def load_skills_from_dir(self, dir_path: str, source: str) -> LoadSkillsResult: + """ + Load skills from a directory. + + Discovery rules: + - Direct .md files in the root directory + - Recursive SKILL.md files under subdirectories + + :param dir_path: Directory path to scan + :param source: Source identifier ('builtin' or 'custom') + :return: LoadSkillsResult with skills and diagnostics + """ + skills = [] + diagnostics = [] + + if not os.path.exists(dir_path): + diagnostics.append(f"Directory does not exist: {dir_path}") + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + if not os.path.isdir(dir_path): + diagnostics.append(f"Path is not a directory: {dir_path}") + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + # Load skills from root-level .md files and subdirectories + result = self._load_skills_recursive(dir_path, source, include_root_files=True) + + return result + + def _load_skills_recursive( + self, + dir_path: str, + source: str, + include_root_files: bool = False + ) -> LoadSkillsResult: + """ + Recursively load skills from a directory. + + If a subdirectory contains its own SKILL.md, it is treated as a + self-contained skill (or skill-collection) and its children are + NOT scanned further. This prevents sub-skills inside a collection + (e.g. style-collection/style-anjing) from being listed as + independent top-level skills. + + :param dir_path: Directory to scan + :param source: Source identifier + :param include_root_files: Whether to include root-level .md files + :return: LoadSkillsResult + """ + skills = [] + diagnostics = [] + + try: + entries = os.listdir(dir_path) + except Exception as e: + diagnostics.append(f"Failed to list directory {dir_path}: {e}") + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + # If this directory has its own SKILL.md, load it and stop recursing. + # The sub-directories are internal resources of this skill. + if not include_root_files and 'SKILL.md' in entries: + skill_md_path = os.path.join(dir_path, 'SKILL.md') + if os.path.isfile(skill_md_path): + skill_result = self._load_skill_from_file(skill_md_path, source) + if skill_result.skills: + skills.extend(skill_result.skills) + diagnostics.extend(skill_result.diagnostics) + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + for entry in entries: + if entry.startswith('.'): + continue + + if entry in ('node_modules', '__pycache__', 'venv', '.git'): + continue + + full_path = os.path.join(dir_path, entry) + + if os.path.isdir(full_path): + sub_result = self._load_skills_recursive(full_path, source, include_root_files=False) + skills.extend(sub_result.skills) + diagnostics.extend(sub_result.diagnostics) + continue + + if not os.path.isfile(full_path): + continue + + is_root_md = include_root_files and entry.endswith('.md') and entry.upper() != 'README.MD' + + if not is_root_md: + continue + + skill_result = self._load_skill_from_file(full_path, source) + if skill_result.skills: + skills.extend(skill_result.skills) + diagnostics.extend(skill_result.diagnostics) + + return LoadSkillsResult(skills=skills, diagnostics=diagnostics) + + def _load_skill_from_file(self, file_path: str, source: str) -> LoadSkillsResult: + """ + Load a single skill from a markdown file. + + :param file_path: Path to the skill markdown file + :param source: Source identifier + :return: LoadSkillsResult + """ + diagnostics = [] + + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as e: + diagnostics.append(f"Failed to read skill file {file_path}: {e}") + return LoadSkillsResult(skills=[], diagnostics=diagnostics) + + # Parse frontmatter + frontmatter = parse_frontmatter(content) + + # Get skill name and description + skill_dir = os.path.dirname(file_path) + parent_dir_name = os.path.basename(skill_dir) + + name = frontmatter.get('name', parent_dir_name) + description = frontmatter.get('description', '') + + # Normalize name (handle both string and list) + if isinstance(name, list): + name = name[0] if name else parent_dir_name + elif not isinstance(name, str): + name = str(name) if name else parent_dir_name + + # Normalize description (handle both string and list) + if isinstance(description, list): + description = ' '.join(str(d) for d in description if d) + elif not isinstance(description, str): + description = str(description) if description else '' + + # Special handling for linkai-agent: dynamically load apps from config.json + if name == 'linkai-agent': + description = self._load_linkai_agent_description(skill_dir, description) + + if not description or not description.strip(): + diagnostics.append(f"Skill {name} has no description: {file_path}") + return LoadSkillsResult(skills=[], diagnostics=diagnostics) + + # Parse disable-model-invocation flag + disable_model_invocation = parse_boolean_value( + get_frontmatter_value(frontmatter, 'disable-model-invocation'), + default=False + ) + + # Create skill object + skill = Skill( + name=name, + description=description, + file_path=file_path, + base_dir=skill_dir, + source=source, + content=content, + disable_model_invocation=disable_model_invocation, + frontmatter=frontmatter, + ) + + return LoadSkillsResult(skills=[skill], diagnostics=diagnostics) + + def _load_linkai_agent_description(self, skill_dir: str, default_description: str) -> str: + """ + Dynamically load LinkAI agent description from config.json + + :param skill_dir: Skill directory + :param default_description: Default description from SKILL.md + :return: Dynamic description with app list + """ + import json + + config_path = os.path.join(skill_dir, "config.json") + + if not os.path.exists(config_path): + logger.debug(f"[SkillLoader] linkai-agent skipped: no config.json found") + return "" + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + apps = config.get("apps", []) + if not apps: + return default_description + + # Build dynamic description with app details + app_descriptions = "; ".join([ + f"{app['app_name']}({app['app_code']}: {app['app_description']})" + for app in apps + ]) + + return f"Call LinkAI apps/workflows. {app_descriptions}" + + except Exception as e: + logger.warning(f"[SkillLoader] Failed to load linkai-agent config: {e}") + return default_description + + def load_all_skills( + self, + builtin_dir: Optional[str] = None, + custom_dir: Optional[str] = None, + ) -> Dict[str, SkillEntry]: + """ + Load skills from builtin and custom directories. + + Precedence (lowest to highest): + 1. builtin — project root ``skills/``, shipped with the codebase + 2. custom — workspace ``skills/``, installed via cloud console or skill creator + + Same-name custom skills override builtin ones. + + :param builtin_dir: Built-in skills directory + :param custom_dir: Custom skills directory + :return: Dictionary mapping skill name to SkillEntry + """ + skill_map: Dict[str, SkillEntry] = {} + all_diagnostics = [] + + # Load builtin skills (lower precedence) + if builtin_dir and os.path.exists(builtin_dir): + result = self.load_skills_from_dir(builtin_dir, source='builtin') + all_diagnostics.extend(result.diagnostics) + for skill in result.skills: + entry = self._create_skill_entry(skill) + skill_map[skill.name] = entry + + # Load custom skills (higher precedence, overrides builtin) + if custom_dir and os.path.exists(custom_dir): + result = self.load_skills_from_dir(custom_dir, source='custom') + all_diagnostics.extend(result.diagnostics) + for skill in result.skills: + entry = self._create_skill_entry(skill) + skill_map[skill.name] = entry + + # Log diagnostics + if all_diagnostics: + logger.debug(f"Skill loading diagnostics: {len(all_diagnostics)} issues") + for diag in all_diagnostics[:5]: + logger.debug(f" - {diag}") + + logger.debug(f"Loaded {len(skill_map)} skills total") + + return skill_map + + def _create_skill_entry(self, skill: Skill) -> SkillEntry: + """ + Create a SkillEntry from a Skill with parsed metadata. + + :param skill: The skill to create an entry for + :return: SkillEntry with metadata + """ + metadata = parse_metadata(skill.frontmatter) + + # Parse user-invocable flag + user_invocable = parse_boolean_value( + get_frontmatter_value(skill.frontmatter, 'user-invocable'), + default=True + ) + + return SkillEntry( + skill=skill, + metadata=metadata, + user_invocable=user_invocable, + ) diff --git a/agent/skills/manager.py b/agent/skills/manager.py new file mode 100644 index 0000000..ddb2a31 --- /dev/null +++ b/agent/skills/manager.py @@ -0,0 +1,361 @@ +""" +Skill manager for managing skill lifecycle and operations. +""" + +import os +import json +from typing import Dict, List, Optional +from pathlib import Path +from common.log import logger +from agent.skills.types import Skill, SkillEntry, SkillSnapshot +from agent.skills.loader import SkillLoader +from agent.skills.formatter import format_skill_entries_for_prompt + +SKILLS_CONFIG_FILE = "skills_config.json" + + +class SkillManager: + """Manages skills for an agent.""" + + def __init__( + self, + builtin_dir: Optional[str] = None, + custom_dir: Optional[str] = None, + config: Optional[Dict] = None, + ): + """ + Initialize the skill manager. + + :param builtin_dir: Built-in skills directory (project root ``skills/``) + :param custom_dir: Custom skills directory (workspace ``skills/``) + :param config: Configuration dictionary + """ + project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + self.builtin_dir = builtin_dir or os.path.join(project_root, 'skills') + self.custom_dir = custom_dir or os.path.join(project_root, 'workspace', 'skills') + self.config = config or {} + self._skills_config_path = os.path.join(self.custom_dir, SKILLS_CONFIG_FILE) + + # skills_config: full skill metadata keyed by name + # { "web-fetch": {"name": ..., "description": ..., "source": ..., "enabled": true}, ... } + self.skills_config: Dict[str, dict] = {} + + self.loader = SkillLoader() + self.skills: Dict[str, SkillEntry] = {} + + # Load skills on initialization + self.refresh_skills() + + def refresh_skills(self): + """Reload all skills from builtin and custom directories, then sync config.""" + self.skills = self.loader.load_all_skills( + builtin_dir=self.builtin_dir, + custom_dir=self.custom_dir, + ) + self._sync_skills_config() + logger.debug(f"SkillManager: Loaded {len(self.skills)} skills") + + # ------------------------------------------------------------------ + # skills_config.json management + # ------------------------------------------------------------------ + def _load_skills_config(self) -> Dict[str, dict]: + """Load skills_config.json from custom_dir. Returns empty dict if not found.""" + if not os.path.exists(self._skills_config_path): + return {} + try: + with open(self._skills_config_path, "r", encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + return data + except Exception as e: + logger.warning(f"[SkillManager] Failed to load {SKILLS_CONFIG_FILE}: {e}") + return {} + + def _save_skills_config(self): + """Persist skills_config to custom_dir/skills_config.json.""" + os.makedirs(self.custom_dir, exist_ok=True) + try: + with open(self._skills_config_path, "w", encoding="utf-8") as f: + json.dump(self.skills_config, f, indent=4, ensure_ascii=False) + except Exception as e: + logger.error(f"[SkillManager] Failed to save {SKILLS_CONFIG_FILE}: {e}") + + def _sync_skills_config(self): + """ + Merge directory-scanned skills with the persisted config file. + + - New skills: use metadata.default_enabled as initial enabled state. + - Existing skills: preserve their persisted enabled state. + - Skills that no longer exist on disk are removed. + - name/description/source are always refreshed from the latest scan. + """ + saved = self._load_skills_config() + merged: Dict[str, dict] = {} + + for name, entry in self.skills.items(): + skill = entry.skill + prev = saved.get(name, {}) + category = prev.get("category", "skill") + + if name in saved: + enabled = prev.get("enabled", True) + else: + enabled = entry.metadata.default_enabled if entry.metadata else True + + entry_dict = { + "name": name, + "description": skill.description, + "source": prev.get("source") or skill.source, + "enabled": enabled, + "category": category, + } + display_name = prev.get("display_name") + if display_name: + entry_dict["display_name"] = display_name + merged[name] = entry_dict + + self.skills_config = merged + self._save_skills_config() + + def is_skill_enabled(self, name: str) -> bool: + """ + Check if a skill is enabled according to skills_config. + + :param name: skill name + :return: True if enabled (default True if not in config) + """ + entry = self.skills_config.get(name) + if entry is None: + return True + return entry.get("enabled", True) + + def set_skill_enabled(self, name: str, enabled: bool): + """ + Set a skill's enabled state and persist. + + :param name: skill name + :param enabled: True to enable, False to disable + """ + if name not in self.skills_config: + raise ValueError(f"skill '{name}' not found in config") + self.skills_config[name]["enabled"] = enabled + self._save_skills_config() + + def get_skills_config(self) -> Dict[str, dict]: + """ + Return the full skills_config dict (for query API). + + :return: copy of skills_config + """ + return dict(self.skills_config) + + def get_skill(self, name: str) -> Optional[SkillEntry]: + """ + Get a skill by name. + + :param name: Skill name + :return: SkillEntry or None if not found + """ + return self.skills.get(name) + + def list_skills(self) -> List[SkillEntry]: + """ + Get all loaded skills. + + :return: List of all skill entries + """ + return list(self.skills.values()) + + @staticmethod + def _normalize_skill_filter(skill_filter: Optional[List[str]]) -> Optional[List[str]]: + """Normalize a skill_filter list into a flat list of stripped names.""" + if skill_filter is None: + return None + normalized = [] + for item in skill_filter: + if isinstance(item, str): + name = item.strip() + if name: + normalized.append(name) + elif isinstance(item, list): + for subitem in item: + if isinstance(subitem, str): + name = subitem.strip() + if name: + normalized.append(name) + return normalized or None + + def filter_skills( + self, + skill_filter: Optional[List[str]] = None, + include_disabled: bool = False, + ) -> List[SkillEntry]: + """ + Filter skills that are eligible (enabled + requirements met). + + :param skill_filter: List of skill names to include (None = all) + :param include_disabled: Whether to include disabled skills + :return: Filtered list of eligible skill entries + """ + from agent.skills.config import should_include_skill + + entries = list(self.skills.values()) + + entries = [e for e in entries if should_include_skill(e, self.config)] + + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] + + if not include_disabled: + entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + + from config import conf + if not conf().get("knowledge", True): + entries = [e for e in entries if e.skill.name != "knowledge-wiki"] + + return entries + + def filter_unavailable_skills( + self, + skill_filter: Optional[List[str]] = None, + ) -> tuple: + """ + Find skills that are enabled but have unmet requirements. + + :param skill_filter: Optional list of skill names to include + :return: Tuple of (entries, missing_map) where missing_map maps + skill name to its missing requirements dict + """ + from agent.skills.config import should_include_skill, get_missing_requirements + + entries = list(self.skills.values()) + + # Only enabled skills + entries = [e for e in entries if self.is_skill_enabled(e.skill.name)] + + normalized = self._normalize_skill_filter(skill_filter) + if normalized is not None: + entries = [e for e in entries if e.skill.name in normalized] + + # Keep only those that fail should_include_skill (requirements not met) + unavailable = [] + missing_map: Dict[str, dict] = {} + for e in entries: + if not should_include_skill(e, self.config): + missing = get_missing_requirements(e) + if missing: + unavailable.append(e) + missing_map[e.skill.name] = missing + + return unavailable, missing_map + + def build_skills_prompt( + self, + skill_filter: Optional[List[str]] = None, + ) -> str: + """ + Build a formatted prompt containing available skills + and brief hints for unavailable ones. + + :param skill_filter: Optional list of skill names to include + :return: Formatted skills prompt + """ + from common.log import logger + from agent.skills.formatter import format_unavailable_skills_for_prompt + + eligible = self.filter_skills(skill_filter=skill_filter, include_disabled=False) + logger.debug(f"[SkillManager] Eligible: {len(eligible)} skills (total: {len(self.skills)})") + if eligible: + skill_names = [e.skill.name for e in eligible] + logger.debug(f"[SkillManager] Eligible skills: {skill_names}") + + result = format_skill_entries_for_prompt(eligible) + + unavailable, missing_map = self.filter_unavailable_skills(skill_filter=skill_filter) + if unavailable: + unavailable_names = [e.skill.name for e in unavailable] + logger.debug(f"[SkillManager] Unavailable skills (setup needed): {unavailable_names}") + result += format_unavailable_skills_for_prompt(unavailable, missing_map) + + logger.debug(f"[SkillManager] Generated prompt length: {len(result)}") + return result + + def build_skill_snapshot( + self, + skill_filter: Optional[List[str]] = None, + version: Optional[int] = None, + ) -> SkillSnapshot: + """ + Build a snapshot of skills for a specific run. + + :param skill_filter: Optional list of skill names to include + :param version: Optional version number for the snapshot + :return: SkillSnapshot + """ + entries = self.filter_skills(skill_filter=skill_filter, include_disabled=False) + prompt = format_skill_entries_for_prompt(entries) + + skills_info = [] + resolved_skills = [] + + for entry in entries: + skills_info.append({ + 'name': entry.skill.name, + 'primary_env': entry.metadata.primary_env if entry.metadata else None, + }) + resolved_skills.append(entry.skill) + + return SkillSnapshot( + prompt=prompt, + skills=skills_info, + resolved_skills=resolved_skills, + version=version, + ) + + def sync_skills_to_workspace(self, target_workspace_dir: str): + """ + Sync all loaded skills to a target workspace directory. + + This is useful for sandbox environments where skills need to be copied. + + :param target_workspace_dir: Target workspace directory + """ + import shutil + + target_skills_dir = os.path.join(target_workspace_dir, 'skills') + + # Remove existing skills directory + if os.path.exists(target_skills_dir): + shutil.rmtree(target_skills_dir) + + # Create new skills directory + os.makedirs(target_skills_dir, exist_ok=True) + + # Copy each skill + for entry in self.skills.values(): + skill_name = entry.skill.name + source_dir = entry.skill.base_dir + target_dir = os.path.join(target_skills_dir, skill_name) + + try: + shutil.copytree(source_dir, target_dir) + logger.debug(f"Synced skill '{skill_name}' to {target_dir}") + except Exception as e: + logger.warning(f"Failed to sync skill '{skill_name}': {e}") + + logger.info(f"Synced {len(self.skills)} skills to {target_skills_dir}") + + def get_skill_by_key(self, skill_key: str) -> Optional[SkillEntry]: + """ + Get a skill by its skill key (which may differ from name). + + :param skill_key: Skill key to look up + :return: SkillEntry or None + """ + for entry in self.skills.values(): + if entry.metadata and entry.metadata.skill_key == skill_key: + return entry + if entry.skill.name == skill_key: + return entry + return None diff --git a/agent/skills/service.py b/agent/skills/service.py new file mode 100644 index 0000000..95cfb9b --- /dev/null +++ b/agent/skills/service.py @@ -0,0 +1,306 @@ +""" +Skill service for handling skill CRUD operations. + +This service provides a unified interface for managing skills, which can be +called from the cloud control client (LinkAI), the local web console, or any +other management entry point. +""" + +import os +import shutil +import zipfile +import tempfile +from typing import Dict, List, Optional +from common.log import logger +from agent.skills.types import Skill, SkillEntry +from agent.skills.manager import SkillManager + +try: + import requests +except ImportError: + requests = None + + +class SkillService: + """ + High-level service for skill lifecycle management. + Wraps SkillManager and provides network-aware operations such as + downloading skill files from remote URLs. + """ + + def __init__(self, skill_manager: SkillManager): + """ + :param skill_manager: The SkillManager instance to operate on + """ + self.manager = skill_manager + + def _safe_skill_dir(self, name: str) -> str: + """Derive and validate the skill directory path. + + Ensures the resolved path stays within the custom_dir root, + preventing path traversal via names like ``../escaped``. + + :raises ValueError: if the name would escape the skills root. + """ + if not name or not name.strip(): + raise ValueError("skill name is required") + # Reject obvious traversal components. + if ".." in name or name.startswith("/") or name.startswith("\\"): + raise ValueError(f"invalid skill name (path traversal detected): {name!r}") + skill_dir = os.path.realpath(os.path.join(self.manager.custom_dir, name)) + root = os.path.realpath(self.manager.custom_dir) + if not skill_dir.startswith(root + os.sep) and skill_dir != root: + raise ValueError( + f"skill name {name!r} resolves outside the skills directory" + ) + return skill_dir + + # ------------------------------------------------------------------ + # query + # ------------------------------------------------------------------ + def query(self) -> List[dict]: + """ + Query all skills and return a serialisable list. + Reads from skills_config.json (refreshes from disk if needed). + + :return: list of skill info dicts + """ + self.manager.refresh_skills() + config = self.manager.get_skills_config() + result = list(config.values()) + logger.info(f"[SkillService] query: {len(result)} skills found") + return result + + # ------------------------------------------------------------------ + # add / install + # ------------------------------------------------------------------ + def add(self, payload: dict) -> None: + """ + Add (install) a skill from a remote payload. + + Supported payload types: + + 1. ``type: "url"`` – download individual files:: + + { + "name": "web_search", + "type": "url", + "enabled": true, + "files": [ + {"url": "https://...", "path": "README.md"}, + {"url": "https://...", "path": "scripts/main.py"} + ] + } + + 2. ``type: "package"`` – download a zip archive and extract:: + + { + "name": "plugin-custom-tool", + "type": "package", + "category": "skills", + "enabled": true, + "files": [{"url": "https://cdn.example.com/skills/custom-tool.zip"}] + } + + :param payload: skill add payload from server + """ + name = payload.get("name") + if not name: + raise ValueError("skill name is required") + + payload_type = payload.get("type", "url") + + if payload_type == "package": + self._add_package(name, payload) + else: + self._add_url(name, payload) + + self.manager.refresh_skills() + + category = payload.get("category") + if category and name in self.manager.skills_config: + self.manager.skills_config[name]["category"] = category + self.manager._save_skills_config() + + def _add_url(self, name: str, payload: dict) -> None: + """Install a skill by downloading individual files.""" + files = payload.get("files", []) + if not files: + raise ValueError("skill files list is empty") + + skill_dir = self._safe_skill_dir(name) + + tmp_dir = skill_dir + ".tmp" + if os.path.exists(tmp_dir): + shutil.rmtree(tmp_dir) + os.makedirs(tmp_dir, exist_ok=True) + + try: + for file_info in files: + url = file_info.get("url") + rel_path = file_info.get("path") + if not url or not rel_path: + logger.warning(f"[SkillService] add: skip invalid file entry {file_info}") + continue + dest = os.path.join(tmp_dir, rel_path) + self._download_file(url, dest) + except Exception: + shutil.rmtree(tmp_dir, ignore_errors=True) + raise + + if os.path.exists(skill_dir): + shutil.rmtree(skill_dir) + os.rename(tmp_dir, skill_dir) + + logger.info(f"[SkillService] add: skill '{name}' installed via url ({len(files)} files)") + + def _add_package(self, name: str, payload: dict) -> None: + """ + Install a skill by downloading a zip archive and extracting it. + + If the archive contains a single top-level directory, that directory + is used as the skill folder directly; otherwise a new directory named + after the skill is created to hold the extracted contents. + """ + files = payload.get("files", []) + if not files or not files[0].get("url"): + raise ValueError("package url is required") + + url = files[0]["url"] + skill_dir = self._safe_skill_dir(name) + + with tempfile.TemporaryDirectory() as tmp_dir: + zip_path = os.path.join(tmp_dir, "package.zip") + self._download_file(url, zip_path) + + if not zipfile.is_zipfile(zip_path): + raise ValueError(f"downloaded file is not a valid zip archive: {url}") + + extract_dir = os.path.join(tmp_dir, "extracted") + with zipfile.ZipFile(zip_path, "r") as zf: + zf.extractall(extract_dir) + + # Determine the actual content root. + # If the zip has a single top-level directory, use its contents + # so the skill folder is clean (no extra nesting). + top_items = [ + item for item in os.listdir(extract_dir) + if not item.startswith(".") + ] + if len(top_items) == 1: + single = os.path.join(extract_dir, top_items[0]) + if os.path.isdir(single): + extract_dir = single + + if os.path.exists(skill_dir): + shutil.rmtree(skill_dir) + shutil.copytree(extract_dir, skill_dir) + + logger.info(f"[SkillService] add: skill '{name}' installed via package ({url})") + + # ------------------------------------------------------------------ + # open / close (enable / disable) + # ------------------------------------------------------------------ + def open(self, payload: dict) -> None: + """ + Enable a skill by name. + + :param payload: {"name": "skill_name"} + """ + name = payload.get("name") + if not name: + raise ValueError("skill name is required") + self.manager.set_skill_enabled(name, enabled=True) + logger.info(f"[SkillService] open: skill '{name}' enabled") + + def close(self, payload: dict) -> None: + """ + Disable a skill by name. + + :param payload: {"name": "skill_name"} + """ + name = payload.get("name") + if not name: + raise ValueError("skill name is required") + self.manager.set_skill_enabled(name, enabled=False) + logger.info(f"[SkillService] close: skill '{name}' disabled") + + # ------------------------------------------------------------------ + # delete + # ------------------------------------------------------------------ + def delete(self, payload: dict) -> None: + """ + Delete a skill by removing its directory entirely. + + :param payload: {"name": "skill_name"} + """ + name = payload.get("name") + if not name: + raise ValueError("skill name is required") + + skill_dir = self._safe_skill_dir(name) + if os.path.exists(skill_dir): + shutil.rmtree(skill_dir) + logger.info(f"[SkillService] delete: removed directory {skill_dir}") + else: + logger.warning(f"[SkillService] delete: skill directory not found: {skill_dir}") + + # Refresh will remove the deleted skill from config automatically + self.manager.refresh_skills() + logger.info(f"[SkillService] delete: skill '{name}' deleted") + + # ------------------------------------------------------------------ + # dispatch - single entry point for protocol messages + # ------------------------------------------------------------------ + def dispatch(self, action: str, payload: Optional[dict] = None) -> dict: + """ + Dispatch a skill management action and return a protocol-compatible + response dict. + + :param action: one of query / add / open / close / delete + :param payload: action-specific payload (may be None for query) + :return: dict with action, code, message, payload + """ + payload = payload or {} + try: + if action == "query": + result_payload = self.query() + return {"action": action, "code": 200, "message": "success", "payload": result_payload} + elif action == "add": + self.add(payload) + elif action == "open": + self.open(payload) + elif action == "close": + self.close(payload) + elif action == "delete": + self.delete(payload) + else: + return {"action": action, "code": 400, "message": f"unknown action: {action}", "payload": None} + return {"action": action, "code": 200, "message": "success", "payload": None} + except Exception as e: + logger.error(f"[SkillService] dispatch error: action={action}, error={e}") + return {"action": action, "code": 500, "message": str(e), "payload": None} + + # ------------------------------------------------------------------ + # internal helpers + # ------------------------------------------------------------------ + @staticmethod + def _download_file(url: str, dest: str): + """ + Download a file from *url* and save to *dest*. + + :param url: remote file URL + :param dest: local destination path + """ + if requests is None: + raise RuntimeError("requests library is required for downloading skill files") + + dest_dir = os.path.dirname(dest) + if dest_dir: + os.makedirs(dest_dir, exist_ok=True) + + resp = requests.get(url, timeout=60) + resp.raise_for_status() + with open(dest, "wb") as f: + f.write(resp.content) + logger.debug(f"[SkillService] downloaded {url} -> {dest}") diff --git a/agent/skills/types.py b/agent/skills/types.py new file mode 100644 index 0000000..a6a467e --- /dev/null +++ b/agent/skills/types.py @@ -0,0 +1,76 @@ +""" +Type definitions for skills system. +""" + +from __future__ import annotations +from typing import Dict, List, Optional, Any +from dataclasses import dataclass, field + + +@dataclass +class SkillInstallSpec: + """Specification for installing skill dependencies.""" + kind: str # brew, pip, npm, download, etc. + id: Optional[str] = None + label: Optional[str] = None + bins: List[str] = field(default_factory=list) + os: List[str] = field(default_factory=list) + formula: Optional[str] = None # for brew + package: Optional[str] = None # for pip/npm + module: Optional[str] = None + url: Optional[str] = None # for download + archive: Optional[str] = None + extract: bool = False + strip_components: Optional[int] = None + target_dir: Optional[str] = None + + +@dataclass +class SkillMetadata: + """Metadata for a skill from frontmatter.""" + always: bool = False # Always include this skill + default_enabled: bool = True # Initial enabled state when first discovered + skill_key: Optional[str] = None # Override skill key + primary_env: Optional[str] = None # Primary environment variable + emoji: Optional[str] = None + homepage: Optional[str] = None + os: List[str] = field(default_factory=list) # Supported OS platforms + requires: Dict[str, List[str]] = field(default_factory=dict) # Requirements + install: List[SkillInstallSpec] = field(default_factory=list) + + +@dataclass +class Skill: + """Represents a skill loaded from a markdown file.""" + name: str + description: str + file_path: str + base_dir: str + source: str # builtin or custom + content: str # Full markdown content + disable_model_invocation: bool = False + frontmatter: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class SkillEntry: + """A skill with parsed metadata.""" + skill: Skill + metadata: Optional[SkillMetadata] = None + user_invocable: bool = True # Can users invoke this skill directly + + +@dataclass +class LoadSkillsResult: + """Result of loading skills from a directory.""" + skills: List[Skill] + diagnostics: List[str] = field(default_factory=list) + + +@dataclass +class SkillSnapshot: + """Snapshot of skills for a specific run.""" + prompt: str # Formatted prompt text + skills: List[Dict[str, str]] # List of skill info (name, primary_env) + resolved_skills: List[Skill] = field(default_factory=list) + version: Optional[int] = None diff --git a/agent/tools/__init__.py b/agent/tools/__init__.py new file mode 100644 index 0000000..1e508c0 --- /dev/null +++ b/agent/tools/__init__.py @@ -0,0 +1,153 @@ +# Import base tool +from agent.tools.base_tool import BaseTool +from agent.tools.tool_manager import ToolManager + +# Import file operation tools +from agent.tools.read.read import Read +from agent.tools.write.write import Write +from agent.tools.edit.edit import Edit +from agent.tools.bash.bash import Bash +from agent.tools.ls.ls import Ls +from agent.tools.send.send import Send + +# Import memory tools +from agent.tools.memory.memory_search import MemorySearchTool +from agent.tools.memory.memory_get import MemoryGetTool + +# Import self-evolution tools +from agent.tools.evolution_undo.evolution_undo import EvolutionUndoTool + +# Import tools with optional dependencies +def _import_optional_tools(): + """Import tools that have optional dependencies""" + from common.log import logger + tools = {} + + # EnvConfig Tool (requires python-dotenv) + try: + from agent.tools.env_config.env_config import EnvConfig + tools['EnvConfig'] = EnvConfig + except ImportError as e: + logger.error( + f"[Tools] EnvConfig tool not loaded - missing dependency: {e}\n" + f" To enable environment variable management, run:\n" + f" pip install python-dotenv>=1.0.0" + ) + except Exception as e: + logger.error(f"[Tools] EnvConfig tool failed to load: {e}") + + # Scheduler Tool (requires croniter) + try: + from agent.tools.scheduler.scheduler_tool import SchedulerTool + tools['SchedulerTool'] = SchedulerTool + except ImportError as e: + logger.error( + f"[Tools] Scheduler tool not loaded - missing dependency: {e}\n" + f" To enable scheduled tasks, run:\n" + f" pip install croniter>=2.0.0" + ) + except Exception as e: + logger.error(f"[Tools] Scheduler tool failed to load: {e}") + + # WebSearch Tool (conditionally loaded based on API key availability at init time) + try: + from agent.tools.web_search.web_search import WebSearch + tools['WebSearch'] = WebSearch + except ImportError as e: + logger.error(f"[Tools] WebSearch not loaded - missing dependency: {e}") + except Exception as e: + logger.error(f"[Tools] WebSearch failed to load: {e}") + + # WebFetch Tool + try: + from agent.tools.web_fetch.web_fetch import WebFetch + tools['WebFetch'] = WebFetch + except ImportError as e: + logger.error(f"[Tools] WebFetch not loaded - missing dependency: {e}") + except Exception as e: + logger.error(f"[Tools] WebFetch failed to load: {e}") + + # Vision Tool (conditionally loaded based on API key availability) + try: + from agent.tools.vision.vision import Vision + tools['Vision'] = Vision + except ImportError as e: + logger.error(f"[Tools] Vision not loaded - missing dependency: {e}") + except Exception as e: + logger.error(f"[Tools] Vision failed to load: {e}") + + return tools + +# Load optional tools +_optional_tools = _import_optional_tools() +EnvConfig = _optional_tools.get('EnvConfig') +SchedulerTool = _optional_tools.get('SchedulerTool') +WebSearch = _optional_tools.get('WebSearch') +WebFetch = _optional_tools.get('WebFetch') +Vision = _optional_tools.get('Vision') +GoogleSearch = _optional_tools.get('GoogleSearch') +FileSave = _optional_tools.get('FileSave') +Terminal = _optional_tools.get('Terminal') + + +# BrowserTool (requires playwright) +def _import_browser_tool(): + from common.log import logger + try: + from agent.tools.browser.browser_tool import BrowserTool + return BrowserTool + except ImportError as e: + logger.info( + f"[Tools] BrowserTool not loaded - missing dependency: {e}\n" + f" To enable browser tool, run:\n" + f" pip install playwright\n" + f" playwright install chromium" + ) + return None + except Exception as e: + logger.error(f"[Tools] BrowserTool failed to load: {e}") + return None + +BrowserTool = _import_browser_tool() + +# MCP Tools (no extra dependencies, loaded on demand) +def _import_mcp_tools(): + """导入 MCP 工具模块(无额外依赖,按需加载)""" + from common.log import logger + try: + from agent.tools.mcp.mcp_tool import McpTool + from agent.tools.mcp.mcp_client import McpClientRegistry + return {'McpTool': McpTool, 'McpClientRegistry': McpClientRegistry} + except Exception as e: + logger.warning(f"[Tools] MCP tools not loaded: {e}") + return {} + +_mcp_tools = _import_mcp_tools() +McpTool = _mcp_tools.get('McpTool') +McpClientRegistry = _mcp_tools.get('McpClientRegistry') + +# Export all tools (including optional ones that might be None) +__all__ = [ + 'BaseTool', + 'ToolManager', + 'Read', + 'Write', + 'Edit', + 'Bash', + 'Ls', + 'Send', + 'MemorySearchTool', + 'MemoryGetTool', + 'EvolutionUndoTool', + 'EnvConfig', + 'SchedulerTool', + 'WebSearch', + 'WebFetch', + 'Vision', + 'BrowserTool', + 'McpTool', +] + +""" +Tools module for Agent. +""" diff --git a/agent/tools/base_tool.py b/agent/tools/base_tool.py new file mode 100644 index 0000000..53f7cf9 --- /dev/null +++ b/agent/tools/base_tool.py @@ -0,0 +1,109 @@ +from enum import Enum +from typing import Any, Optional +from common.log import logger +import copy + + +class ToolStage(Enum): + """Enum representing tool decision stages""" + PRE_PROCESS = "pre_process" # Tools that need to be actively selected by the agent + POST_PROCESS = "post_process" # Tools that automatically execute after final_answer + + +class ToolResult: + """Tool execution result""" + + def __init__(self, status: str = None, result: Any = None, ext_data: Any = None): + self.status = status + self.result = result + self.ext_data = ext_data + + @staticmethod + def success(result, ext_data: Any = None): + return ToolResult(status="success", result=result, ext_data=ext_data) + + @staticmethod + def fail(result, ext_data: Any = None): + return ToolResult(status="error", result=result, ext_data=ext_data) + + +class BaseTool: + """Base class for all tools.""" + + # Default decision stage is pre-process + stage = ToolStage.PRE_PROCESS + + # Class attributes must be inherited + name: str = "base_tool" + description: str = "Base tool" + params: dict = {} # Store JSON Schema + model: Optional[Any] = None # LLM model instance, type depends on bot implementation + progress_callback = None + + def report_progress(self, message: str): + callback = getattr(self, "progress_callback", None) + if not callback: + return + try: + callback(str(message)) + except Exception as e: + logger.debug(f"[{self.name}] progress callback failed: {e}") + + @classmethod + def get_json_schema(cls) -> dict: + """Get the standard description of the tool""" + return { + "name": cls.name, + "description": cls.description, + "parameters": cls.params + } + + def execute_tool(self, params: dict) -> ToolResult: + try: + return self.execute(params) + except Exception as e: + logger.error(e) + + def execute(self, params: dict) -> ToolResult: + """Specific logic to be implemented by subclasses""" + raise NotImplementedError + + @classmethod + def _parse_schema(cls) -> dict: + """Convert JSON Schema to Pydantic fields""" + fields = {} + for name, prop in cls.params["properties"].items(): + # Convert JSON Schema types to Python types + type_map = { + "string": str, + "number": float, + "integer": int, + "boolean": bool, + "array": list, + "object": dict + } + fields[name] = ( + type_map[prop["type"]], + prop.get("default", ...) + ) + return fields + + def should_auto_execute(self, context) -> bool: + """ + Determine if this tool should be automatically executed based on context. + + :param context: The agent context + :return: True if the tool should be executed, False otherwise + """ + # Only tools in post-process stage will be automatically executed + return self.stage == ToolStage.POST_PROCESS + + def close(self): + """ + Close any resources used by the tool. + This method should be overridden by tools that need to clean up resources + such as browser connections, file handles, etc. + + By default, this method does nothing. + """ + pass diff --git a/agent/tools/bash/__init__.py b/agent/tools/bash/__init__.py new file mode 100644 index 0000000..bbd4bb0 --- /dev/null +++ b/agent/tools/bash/__init__.py @@ -0,0 +1,3 @@ +from .bash import Bash + +__all__ = ['Bash'] diff --git a/agent/tools/bash/bash.py b/agent/tools/bash/bash.py new file mode 100644 index 0000000..3e2bf30 --- /dev/null +++ b/agent/tools/bash/bash.py @@ -0,0 +1,455 @@ +""" +Bash tool - Execute bash commands +""" + +import os +import re +import signal +import sys +import subprocess +import tempfile +import threading +import time +from typing import Dict, Any + +from agent.tools.base_tool import BaseTool, ToolResult +from agent.tools.utils.truncate import truncate_tail, format_size, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES +from common.log import logger +from common.utils import expand_path + + +class Bash(BaseTool): + """Tool for executing bash commands""" + + _IS_WIN = sys.platform == "win32" + _PROGRESS_MAX_BYTES = 4 * 1024 + _PROGRESS_INTERVAL = 0.5 + # cmd.exe command line limit is ~8191 chars; rewrite python -c above this. + _WIN_CMD_SAFE_LEN = 7000 + + name: str = "bash" + description: str = f"""Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {DEFAULT_MAX_BYTES // 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. +{''' +PLATFORM: Windows (cmd.exe). Do NOT use Unix-only commands like grep, head, tail, sed, awk. +''' if _IS_WIN else ''} +ENVIRONMENT: All API keys from env_config are auto-injected. Use $VAR_NAME directly. + +SAFETY: +- Freely create/modify/delete files within the workspace +- For destructive commands out of workspace, explain and confirm first""" + + params: dict = { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Bash command to execute" + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds (optional, default: 30)" + } + }, + "required": ["command"] + } + + def __init__(self, config: dict = None): + self.config = config or {} + self.cwd = self.config.get("cwd", os.getcwd()) + # Ensure working directory exists + if not os.path.exists(self.cwd): + os.makedirs(self.cwd, exist_ok=True) + self.default_timeout = self.config.get("timeout", 30) + # Enable safety mode by default (can be disabled in config) + self.safety_mode = self.config.get("safety_mode", True) + + def execute(self, args: Dict[str, Any]) -> ToolResult: + """ + Execute a bash command + + :param args: Dictionary containing the command and optional timeout + :return: Command output or error + """ + command = args.get("command", "").strip() + timeout = args.get("timeout", self.default_timeout) + + if not command: + return ToolResult.fail("Error: command parameter is required") + + # Security check: Prevent direct access to the credential file + if re.search(r'\.cow[/\\]\.env', command): + return ToolResult.fail( + "Error: Access denied. API keys and credentials must be accessed through the env_config tool only." + ) + + # Optional safety check - only warn about extremely dangerous commands + if self.safety_mode: + warning = self._get_safety_warning(command) + if warning: + return ToolResult.fail( + f"Safety Warning: {warning}\n\nIf you believe this command is safe and necessary, please ask the user for confirmation first, explaining what the command does and why it's needed.") + + try: + # Prepare environment with .env file variables + env = os.environ.copy() + + # Load environment variables from ~/.cow/.env if it exists + env_file = expand_path("~/.cow/.env") + dotenv_vars = {} + if os.path.exists(env_file): + try: + from dotenv import dotenv_values + dotenv_vars = dotenv_values(env_file) + env.update(dotenv_vars) + logger.debug(f"[Bash] Loaded {len(dotenv_vars)} variables from {env_file}") + except ImportError: + logger.debug("[Bash] python-dotenv not installed, skipping .env loading") + except Exception as e: + logger.debug(f"[Bash] Failed to load .env: {e}") + + # getuid() only exists on Unix-like systems + if hasattr(os, 'getuid'): + logger.debug(f"[Bash] Process UID: {os.getuid()}") + else: + logger.debug(f"[Bash] Process User: {os.environ.get('USERNAME', os.environ.get('USER', 'unknown'))}") + + # Temp script written for long `python -c` commands (Windows only), + # cleaned up after execution. + temp_script_path = None + + # On Windows, convert $VAR references to %VAR% for cmd.exe + if self._IS_WIN: + env["PYTHONIOENCODING"] = "utf-8" + command = self._convert_env_vars_for_windows(command, dotenv_vars) + # cmd.exe has an ~8191 char command line limit. Long + # `python -c "..."` commands silently fail, so spill the inline + # code into a temp .py file and run that instead. + if len(command) > self._WIN_CMD_SAFE_LEN: + command, temp_script_path = self._rewrite_long_python_c(command) + if command and not command.strip().lower().startswith("chcp"): + command = f"chcp 65001 >nul 2>&1 && {command}" + + try: + result = self._run_streaming( + command, + timeout, + env, + dotenv_vars, + ) + finally: + if temp_script_path: + try: + os.remove(temp_script_path) + except OSError: + pass + + logger.debug(f"[Bash] Exit code: {result.returncode}") + logger.debug(f"[Bash] Stdout length: {len(result.stdout)}") + logger.debug(f"[Bash] Stderr length: {len(result.stderr)}") + + # Workaround for exit code 126 with no output + if result.returncode == 126 and not result.stdout and not result.stderr: + logger.warning(f"[Bash] Exit 126 with no output - trying alternative execution method") + # Try using argument list instead of shell=True + import shlex + try: + parts = shlex.split(command) + if len(parts) > 0: + logger.info(f"[Bash] Retrying with argument list: {parts[:3]}...") + retry_result = subprocess.run( + parts, + cwd=self.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + env=env + ) + logger.debug(f"[Bash] Retry exit code: {retry_result.returncode}, stdout: {len(retry_result.stdout)}, stderr: {len(retry_result.stderr)}") + + # If retry succeeded, use retry result + if retry_result.returncode == 0 or retry_result.stdout or retry_result.stderr: + result = retry_result + else: + # Both attempts failed - check if this is openai-image-vision skill + if 'openai-image-vision' in command or 'vision.sh' in command: + # Create a mock result with helpful error message + from types import SimpleNamespace + result = SimpleNamespace( + returncode=1, + stdout='{"error": "图片无法解析", "reason": "该图片格式可能不受支持,或图片文件存在问题", "suggestion": "请尝试其他图片"}', + stderr='' + ) + logger.info(f"[Bash] Converted exit 126 to user-friendly image error message for vision skill") + except Exception as retry_err: + logger.warning(f"[Bash] Retry failed: {retry_err}") + + # When command succeeds with stdout, keep output clean (stderr goes to server log only). + # When command fails or stdout is empty, include stderr so the agent can diagnose. + if result.returncode == 0 and result.stdout.strip(): + output = result.stdout + if result.stderr: + logger.info(f"[Bash] stderr (not forwarded): {result.stderr[:500]}") + else: + output = result.stdout + if result.stderr: + output += "\n" + result.stderr + + # Check if we need to save full output to temp file + temp_file_path = None + total_bytes = len(output.encode('utf-8')) + + if total_bytes > DEFAULT_MAX_BYTES: + # Save full output to temp file. encoding='utf-8' is required: + # the default text-mode encoding is the platform locale (e.g. + # cp936/GBK on Chinese Windows), which raises UnicodeEncodeError + # for output containing emoji or other non-locale characters and + # would discard an otherwise successful command result. + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.log', prefix='bash-', encoding='utf-8') as f: + f.write(output) + temp_file_path = f.name + + # Apply tail truncation + truncation = truncate_tail(output) + output_text = truncation.content or "(no output)" + + # Build result + details = {} + + if truncation.truncated: + details["truncation"] = truncation.to_dict() + if temp_file_path: + details["full_output_path"] = temp_file_path + + # Build notice + start_line = truncation.total_lines - truncation.output_lines + 1 + end_line = truncation.total_lines + + if truncation.last_line_partial: + # Edge case: last line alone > 30KB + last_line = output.split('\n')[-1] if output else "" + last_line_size = format_size(len(last_line.encode('utf-8'))) + output_text += f"\n\n[Showing last {format_size(truncation.output_bytes)} of line {end_line} (line is {last_line_size}). Full output: {temp_file_path}]" + elif truncation.truncated_by == "lines": + output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines}. Full output: {temp_file_path}]" + else: + output_text += f"\n\n[Showing lines {start_line}-{end_line} of {truncation.total_lines} ({format_size(DEFAULT_MAX_BYTES)} limit). Full output: {temp_file_path}]" + + # Check exit code + if result.returncode != 0: + output_text += f"\n\nCommand exited with code {result.returncode}" + return ToolResult.fail({ + "output": output_text, + "exit_code": result.returncode, + "details": details if details else None + }) + + return ToolResult.success({ + "output": output_text, + "exit_code": result.returncode, + "details": details if details else None + }) + + except subprocess.TimeoutExpired: + return ToolResult.fail(f"Error: Command timed out after {timeout} seconds") + except Exception as e: + return ToolResult.fail(f"Error executing command: {str(e)}") + + def _run_streaming(self, command: str, timeout: int, env: dict, dotenv_vars: dict): + process = subprocess.Popen( + command, + shell=True, + cwd=self.cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + start_new_session=not self._IS_WIN, + ) + stdout_chunks, stderr_chunks = [], [] + recent = bytearray() + recent_lock = threading.Lock() + + def drain(stream, chunks): + while True: + chunk = os.read(stream.fileno(), 4096) + if not chunk: + break + chunks.append(chunk) + with recent_lock: + recent.extend(chunk) + if len(recent) > self._PROGRESS_MAX_BYTES: + del recent[:-self._PROGRESS_MAX_BYTES] + + readers = [ + threading.Thread(target=drain, args=(process.stdout, stdout_chunks), daemon=True), + threading.Thread(target=drain, args=(process.stderr, stderr_chunks), daemon=True), + ] + for reader in readers: + reader.start() + + started = time.monotonic() + last_reported_at = started + last_snapshot = None + try: + while process.poll() is None: + now = time.monotonic() + elapsed = now - started + if elapsed >= timeout: + self._kill_process(process) + raise subprocess.TimeoutExpired(command, timeout) + if elapsed >= self._PROGRESS_INTERVAL and now - last_reported_at >= self._PROGRESS_INTERVAL: + with recent_lock: + snapshot = bytes(recent).decode("utf-8", errors="replace") + snapshot = self._redact_progress(snapshot, dotenv_vars) + if snapshot and snapshot != last_snapshot: + self.report_progress(snapshot) + last_snapshot = snapshot + last_reported_at = now + time.sleep(0.1) + finally: + if process.poll() is None: + self._kill_process(process) + process.wait() + join_deadline = time.monotonic() + 5 + for reader in readers: + reader.join(timeout=max(0, join_deadline - time.monotonic())) + + from types import SimpleNamespace + return SimpleNamespace( + returncode=process.returncode, + stdout=b"".join(stdout_chunks).decode("utf-8", errors="replace"), + stderr=b"".join(stderr_chunks).decode("utf-8", errors="replace"), + ) + + def _kill_process(self, process): + if self._IS_WIN: + try: + result = subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(process.pid)], + capture_output=True, + timeout=5, + ) + if result.returncode != 0 and process.poll() is None: + process.kill() + except (OSError, subprocess.SubprocessError): + if process.poll() is None: + process.kill() + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except (PermissionError, ProcessLookupError): + if process.poll() is None: + process.kill() + + @staticmethod + def _redact_progress(text: str, dotenv_vars: dict) -> str: + text = re.sub( + r'(?i)\b(API_KEY|TOKEN|PASSWORD|AUTHORIZATION)\s*=\s*[^\s]+', + lambda match: f"{match.group(1)}=[REDACTED]", + text, + ) + for value in dotenv_vars.values(): + value = str(value or "") + if len(value) >= 6: + text = text.replace(value, "[REDACTED]") + return text + + def _get_safety_warning(self, command: str) -> str: + """ + Get safety warning for absolutely catastrophic commands only. + Keep the blocklist minimal so the agent retains maximum freedom. + + :param command: Command to check + :return: Warning message if dangerous, empty string if safe + """ + # Tokenize to avoid substring false positives (e.g. `rm -rf /tmp/x` + # must not match `rm -rf /`). + tokens = command.lower().split() + + # `rm -rf /` or `rm -rf /*` targeting the real root. + for i, tok in enumerate(tokens): + if tok != "rm": + continue + has_rf = False + for j in range(i + 1, len(tokens)): + t = tokens[j] + if t.startswith("-") and "r" in t and "f" in t: + has_rf = True + elif t in ("--recursive", "--force"): + continue + elif t in ("/", "/*"): + if has_rf: + return "This command will delete the entire filesystem" + break + else: + break + + # Disk wiping + if "if=/dev/zero" in command.lower() and "dd " in command.lower(): + return "This command can destroy disk data" + + # Power control - match only as a standalone word (\b enforces word boundary) + if re.search(r'\b(shutdown|reboot|halt|poweroff)\b', command.lower()): + return "This command will shut down or restart the system" + + return "" + + @staticmethod + def _convert_env_vars_for_windows(command: str, dotenv_vars: dict) -> str: + """ + Convert bash-style $VAR / ${VAR} references to cmd.exe %VAR% syntax. + Only converts variables loaded from .env (user-configured API keys etc.) + to avoid breaking $PATH, jq expressions, regex, etc. + """ + if not dotenv_vars: + return command + + def replace_match(m): + var_name = m.group(1) or m.group(2) + if var_name in dotenv_vars: + return f"%{var_name}%" + return m.group(0) + + return re.sub(r'\$\{(\w+)\}|\$(\w+)', replace_match, command) + + @staticmethod + def _rewrite_long_python_c(command: str): + """ + Rewrite `python -c ""` into `python ` to bypass the + cmd.exe command line length limit on Windows. + + Returns (new_command, temp_file_path). On any parse failure the original + command and None are returned, so behavior is unchanged when unmatched. + """ + # Match: [flags] -c "" (single or double quoted) + m = re.search( + r'^(?P.*?\b(?:python3?|py)\b[^\n]*?\s-c\s+)' + r'(?P["\'])(?P.*)(?P=quote)\s*(?P.*)$', + command, + re.DOTALL, + ) + if not m: + return command, None + + quote = m.group("quote") + code = m.group("code") + # Reverse common shell-level escaping of the quote char inside the code. + code = code.replace("\\" + quote, quote) + + try: + fd, path = tempfile.mkstemp(suffix=".py", prefix="bash-pyc-") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(code) + except OSError: + return command, None + + prefix = m.group("prefix") + # Drop the trailing "-c " from the prefix, keep the interpreter + flags. + interp = re.sub(r'\s-c\s+$', ' ', prefix).rstrip() + suffix = m.group("suffix").strip() + new_command = f'{interp} "{path}"' + if suffix: + new_command += f' {suffix}' + return new_command, path diff --git a/agent/tools/browser/__init__.py b/agent/tools/browser/__init__.py new file mode 100644 index 0000000..8a5e733 --- /dev/null +++ b/agent/tools/browser/__init__.py @@ -0,0 +1,3 @@ +from agent.tools.browser.browser_tool import BrowserTool + +__all__ = ["BrowserTool"] diff --git a/agent/tools/browser/browser_service.py b/agent/tools/browser/browser_service.py new file mode 100644 index 0000000..f499fb2 --- /dev/null +++ b/agent/tools/browser/browser_service.py @@ -0,0 +1,961 @@ +""" +Browser service - Playwright wrapper managing browser lifecycle and page operations. + +All Playwright calls run on a dedicated background thread so that callers from +any worker thread can safely use the service. An idle-timeout mechanism +automatically shuts down the browser (and its thread) after a configurable +period of inactivity to free resources. +""" + +import os +import sys +import uuid +import queue +import threading +from typing import Optional, Dict, Any, List, Callable + +from common.log import logger +from common.utils import expand_path, is_cloud_deployment + + +_DEFAULT_USER_DATA_DIR = "~/.cow/browser_profile" + +try: + from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page, Playwright + _HAS_PLAYWRIGHT = True +except ImportError: + _HAS_PLAYWRIGHT = False + + +# --------------------------------------------------------------------------- +# Snapshot DOM helpers +# --------------------------------------------------------------------------- + +# Tags that typically carry useful content for an agent +_INTERACTIVE_TAGS = { + "a", "button", "input", "textarea", "select", "option", + "label", "details", "summary", +} +_SEMANTIC_TAGS = { + "h1", "h2", "h3", "h4", "h5", "h6", + "p", "li", "td", "th", "caption", "figcaption", "blockquote", "pre", "code", + "nav", "main", "article", "section", "header", "footer", "form", "table", + "img", "video", "audio", +} +_KEEP_TAGS = _INTERACTIVE_TAGS | _SEMANTIC_TAGS + +_SNAPSHOT_JS = """ +() => { + const KEEP = new Set(%s); + const INTERACTIVE = new Set(%s); + const SKIP = new Set(["script","style","noscript","svg","path","meta","link","br","hr"]); + const CLICKABLE_ROLES = new Set([ + "button","link","tab","menuitem","menuitemcheckbox","menuitemradio", + "option","switch","checkbox","radio","combobox","searchbox","slider", + "spinbutton","textbox","treeitem" + ]); + let refCounter = 0; + const refMap = {}; + + function visible(el) { + if (!(el instanceof HTMLElement)) return true; + const st = window.getComputedStyle(el); + if (st.display === "none" || st.visibility === "hidden") return false; + if (parseFloat(st.opacity) === 0) return false; + return true; + } + + // Strong signals: these attributes alone are enough to mark as interactive + function hasStrongInteractiveSignal(el) { + const role = el.getAttribute("role"); + if (role && CLICKABLE_ROLES.has(role)) return true; + if (el.hasAttribute("onclick") || el.hasAttribute("tabindex")) return true; + if (el.hasAttribute("data-click") || el.hasAttribute("data-action")) return true; + if (el.getAttribute("contenteditable") === "true") return true; + return false; + } + + // Check if cursor:pointer is set directly (not just inherited from parent) + function hasOwnPointerCursor(el) { + try { + const st = window.getComputedStyle(el); + if (st.cursor !== "pointer") return false; + const parent = el.parentElement; + if (parent) { + const pst = window.getComputedStyle(parent); + if (pst.cursor === "pointer") return false; + } + return true; + } catch(e) {} + return false; + } + + function hasTextOrContent(el) { + const t = el.textContent || ""; + if (t.trim().length > 0) return true; + if (el.querySelector("img,video,audio,canvas")) return true; + const ariaLabel = el.getAttribute("aria-label"); + if (ariaLabel && ariaLabel.trim()) return true; + const title = el.getAttribute("title"); + if (title && title.trim()) return true; + return false; + } + + function isImplicitInteractive(el) { + if (hasStrongInteractiveSignal(el)) return true; + if (hasOwnPointerCursor(el) && hasTextOrContent(el)) return true; + return false; + } + + function getTextContent(el) { + let text = ""; + for (const ch of el.childNodes) { + if (ch.nodeType === Node.TEXT_NODE) { + text += ch.textContent; + } + } + return text.trim(); + } + + function walk(node) { + if (node.nodeType === Node.TEXT_NODE) { + const t = node.textContent.trim(); + return t ? t : null; + } + if (node.nodeType !== Node.ELEMENT_NODE) return null; + const tag = node.tagName.toLowerCase(); + if (SKIP.has(tag)) return null; + if (!visible(node)) return null; + + const children = []; + for (const ch of node.childNodes) { + const r = walk(ch); + if (r !== null) { + if (typeof r === "string") children.push(r); + else children.push(r); + } + } + + const nativeInteractive = INTERACTIVE.has(tag); + const implicitInteractive = !nativeInteractive && (node instanceof HTMLElement) && isImplicitInteractive(node); + const keep = KEEP.has(tag) || implicitInteractive; + + if (!keep) { + if (children.length === 0) return null; + if (children.length === 1) return children[0]; + return children; + } + + const obj = { tag }; + if (nativeInteractive || implicitInteractive) { + refCounter++; + obj.ref = refCounter; + refMap[refCounter] = node; + } + + if (implicitInteractive) { + const role = node.getAttribute("role"); + if (role) obj.role = role; + const directText = getTextContent(node); + if (!directText && children.length === 0) { + const ariaLabel = node.getAttribute("aria-label"); + const title = node.getAttribute("title"); + if (ariaLabel) obj.ariaLabel = ariaLabel; + else if (title) obj.ariaLabel = title; + } + } + + // Attributes + if (tag === "a" && node.href) obj.href = node.getAttribute("href"); + if (tag === "img") { + obj.alt = node.alt || ""; + obj.src = node.getAttribute("src") || ""; + } + if (tag === "input" || tag === "textarea" || tag === "select") { + obj.type = node.type || "text"; + obj.name = node.name || undefined; + obj.value = node.value || undefined; + obj.placeholder = node.placeholder || undefined; + if (node.disabled) obj.disabled = true; + if (tag === "input" && node.type === "checkbox") obj.checked = node.checked; + } + if (tag === "button") { + if (node.disabled) obj.disabled = true; + } + if (tag === "option") { + obj.value = node.value; + if (node.selected) obj.selected = true; + } + if (tag === "label" && node.htmlFor) obj.for = node.htmlFor; + + // Role / aria-label for native interactive & semantic elements + if (!implicitInteractive) { + const role = node.getAttribute("role"); + if (role) obj.role = role; + const ariaLabel = node.getAttribute("aria-label"); + if (ariaLabel) obj.ariaLabel = ariaLabel; + } + + // Children + if (children.length === 1 && typeof children[0] === "string") { + obj.text = children[0]; + } else if (children.length > 0) { + obj.children = children; + } + + return obj; + } + + const result = walk(document.body); + window.__cowRefMap = refMap; + return { tree: result, refCount: refCounter }; +} +""" % ( + str(list(_KEEP_TAGS)), + str(list(_INTERACTIVE_TAGS)), +) + + +_BROWSER_DEAD_HINTS = ( + "has been closed", + "browser has disconnected", + "target closed", + "browser closed", + "context or browser has been closed", +) + + +def _is_browser_dead_error(err: Exception) -> bool: + """Return True if *err* indicates the browser / page died out from under us.""" + msg = str(err).lower() + return any(h in msg for h in _BROWSER_DEAD_HINTS) + + +def _should_use_headless() -> bool: + """Decide headless mode: headless on Linux servers without display, headed elsewhere.""" + if sys.platform in ("win32", "darwin"): + return False + # Linux: check for display + if os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"): + return False + return True + + +def _flatten_tree(node, indent=0) -> List[str]: + """Convert snapshot tree to compact text lines for LLM consumption.""" + if node is None: + return [] + if isinstance(node, str): + return [" " * indent + node] + if isinstance(node, list): + lines = [] + for child in node: + lines.extend(_flatten_tree(child, indent)) + return lines + if not isinstance(node, dict): + return [] + + tag = node.get("tag", "?") + ref = node.get("ref") + parts = [tag] + if ref: + parts[0] = f"[{ref}] {tag}" + + # Inline attributes + for attr in ("type", "name", "href", "alt", "role", "ariaLabel", "placeholder", "value"): + val = node.get(attr) + if val: + # Truncate long values + s = str(val) + if len(s) > 80: + s = s[:77] + "..." + parts.append(f'{attr}="{s}"') + + for flag in ("disabled", "checked", "selected"): + if node.get(flag): + parts.append(flag) + + prefix = " " * indent + header = prefix + " ".join(parts) + + text = node.get("text") + if text: + # Truncate long text + if len(text) > 120: + text = text[:117] + "..." + header += f": {text}" + + lines = [header] + children = node.get("children", []) + for child in children: + lines.extend(_flatten_tree(child, indent + 2)) + return lines + + +class BrowserService: + """Manages a Playwright browser on a dedicated background thread. + + All Playwright operations are dispatched to a single long-lived thread via + a task queue. Callers from *any* worker thread can use the public API + safely. An idle timer automatically shuts the browser down after + ``idle_timeout`` seconds of inactivity (default 300 = 5 min). + """ + + _IDLE_TIMEOUT_DEFAULT = 300 # seconds + + def __init__(self, config: Optional[Dict[str, Any]] = None): + self._config = config or {} + self._headless: Optional[bool] = None + self._screenshot_dir: Optional[str] = None + + # Background thread state + self._thread: Optional[threading.Thread] = None + self._task_queue: queue.Queue = queue.Queue() + self._lock = threading.Lock() + self._alive = False + self._ready = threading.Event() + + # Playwright objects (only accessed on the background thread) + self._playwright = None + self._browser = None + self._context = None + self._page = None + + # Launch mode: one of "fresh" | "persistent" | "cdp". + # - cdp: connect to an externally launched Chrome via CDP endpoint. + # - persistent: launch with launch_persistent_context using a user_data_dir + # so cookies / login state survive across runs (default). + # - fresh: classic launch + new_context, clean state every run. + cdp_endpoint = self._config.get("cdp_endpoint") or "" + persistent_flag = self._config.get("persistent", True) + user_data_dir_cfg = self._config.get("user_data_dir") + if user_data_dir_cfg is None: + user_data_dir_cfg = _DEFAULT_USER_DATA_DIR + + self._cdp_endpoint: str = cdp_endpoint.strip() if isinstance(cdp_endpoint, str) else "" + if self._cdp_endpoint: + self._launch_mode = "cdp" + self._user_data_dir: str = "" + elif persistent_flag and user_data_dir_cfg: + self._launch_mode = "persistent" + self._user_data_dir = expand_path(str(user_data_dir_cfg)) + else: + self._launch_mode = "fresh" + self._user_data_dir = "" + + # Idle auto-release + idle_cfg = self._config.get("idle_timeout") + self._idle_timeout: float = float(idle_cfg) if idle_cfg is not None else self._IDLE_TIMEOUT_DEFAULT + self._idle_timer: Optional[threading.Timer] = None + + # Set when the browser / page is detected to have died externally + # (e.g. user manually closed the window). The next _submit() will then + # tear down the stale thread and relaunch. + self._needs_restart = False + + # ------------------------------------------------------------------ + # Background-thread lifecycle + # ------------------------------------------------------------------ + + def _start_thread(self): + """Start the dedicated Playwright thread if not already running.""" + with self._lock: + if self._alive and self._thread and self._thread.is_alive(): + return + # Wait for old thread to fully exit before creating a new one + old = self._thread + if old and old.is_alive(): + old.join(timeout=5) + # Fresh queue to avoid stale sentinels from a previous close() + self._task_queue = queue.Queue() + self._alive = True + self._ready = threading.Event() + self._thread = threading.Thread(target=self._run_loop, daemon=True, name="BrowserThread") + self._thread.start() + # Block until browser is ready (or failed) + self._ready.wait(timeout=30) + + def _run_loop(self): + """Event loop running on the dedicated thread. Processes tasks until stopped.""" + logger.info("[Browser] Background thread started") + try: + self._launch_browser() + except Exception as e: + logger.error(f"[Browser] Failed to launch browser: {e}") + self._alive = False + self._ready.set() + self._drain_queue(RuntimeError(f"Browser launch failed: {e}")) + return + self._ready.set() + + while self._alive: + try: + task = self._task_queue.get(timeout=1.0) + except queue.Empty: + continue + if task is None: + break + fn, args, kwargs, result_slot = task + try: + result_slot["value"] = fn(*args, **kwargs) + except Exception as e: + result_slot["error"] = e + if _is_browser_dead_error(e): + self._needs_restart = True + logger.warning( + f"[Browser] Detected closed page/context ({e}); " + "will relaunch on next request." + ) + finally: + result_slot["event"].set() + + self._shutdown_browser() + self._drain_queue(RuntimeError("Browser thread stopped")) + logger.info("[Browser] Background thread exited") + + def _drain_queue(self, error: Exception): + """Unblock all callers waiting on the queue with an error.""" + while True: + try: + task = self._task_queue.get_nowait() + except queue.Empty: + break + if task is None: + continue + _, _, _, result_slot = task + result_slot["error"] = error + result_slot["event"].set() + + def _launch_browser(self): + """Launch / connect Chromium on the background thread.""" + if self._headless is None: + headless_cfg = self._config.get("headless") + self._headless = headless_cfg if headless_cfg is not None else _should_use_headless() + + launch_args = ["--disable-dev-shm-usage"] + if self._headless: + launch_args.append("--no-sandbox") + + if is_cloud_deployment(): + launch_args.extend([ + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-extensions", + "--disable-background-networking", + "--disable-background-timer-throttling", + "--disable-renderer-backgrounding", + "--disable-features=site-per-process,TranslateUI,IsolateOrigins", + "--no-zygote", + "--js-flags=--max-old-space-size=384", + "--memory-pressure-off", + ]) + + extra_args = self._config.get("launch_args", []) + if extra_args: + launch_args.extend(extra_args) + + viewport_w = self._config.get("viewport_width", 1280) + viewport_h = self._config.get("viewport_height", 720) + viewport = {"width": viewport_w, "height": viewport_h} + user_agent = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/131.0.0.0 Safari/537.36" + ) + + self._playwright = sync_playwright().start() + + if self._launch_mode == "cdp": + self._connect_cdp(viewport) + elif self._launch_mode == "persistent": + self._launch_persistent(launch_args, viewport, user_agent) + else: + self._launch_fresh(launch_args, viewport, user_agent) + + logger.info("[Browser] Browser ready") + + def _launch_fresh(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): + """Classic launch: brand new Chromium with an empty context.""" + logger.info(f"[Browser] Launching Chromium (fresh, headless={self._headless})") + self._browser = self._playwright.chromium.launch( + headless=self._headless, + args=launch_args, + ) + self._context = self._browser.new_context( + viewport=viewport, + user_agent=user_agent, + ) + self._page = self._context.new_page() + self._wire_close_listeners() + + def _launch_persistent(self, launch_args: List[str], viewport: Dict[str, int], user_agent: str): + """Launch Chromium with a persistent user_data_dir so login state survives.""" + os.makedirs(self._user_data_dir, exist_ok=True) + logger.info( + f"[Browser] Launching Chromium (persistent, headless={self._headless}, " + f"profile={self._user_data_dir})" + ) + try: + self._context = self._playwright.chromium.launch_persistent_context( + user_data_dir=self._user_data_dir, + headless=self._headless, + args=launch_args, + viewport=viewport, + user_agent=user_agent, + ) + except Exception as e: + # Profile is locked when another Chromium instance already holds it. + msg = str(e).lower() + if "singletonlock" in msg or "profile" in msg or "lock" in msg: + raise RuntimeError( + f"Browser profile '{self._user_data_dir}' is in use by another process. " + "Close the other Chromium / cow instance, or set a different " + "tools.browser.user_data_dir." + ) from e + raise + + # Persistent context has no parent Browser handle; reuse the auto-created page. + self._browser = None + pages = self._context.pages + self._page = pages[0] if pages else self._context.new_page() + self._wire_close_listeners() + + def _connect_cdp(self, viewport: Dict[str, int]): + """Attach to an existing Chrome started with --remote-debugging-port.""" + endpoint = self._cdp_endpoint + logger.info(f"[Browser] Connecting to existing Chrome via CDP: {endpoint}") + try: + self._browser = self._playwright.chromium.connect_over_cdp(endpoint) + except Exception as e: + msg = str(e).lower() + if "econnrefused" in msg or "connect" in msg or "refused" in msg: + raise RuntimeError( + f"Cannot reach Chrome at {endpoint}. The CDP browser is not " + "running. Ask the user to launch Chrome with " + "--remote-debugging-port and --user-data-dir, then retry. " + "Do not retry this tool until the user confirms." + ) from e + raise + + contexts = self._browser.contexts + if contexts: + self._context = contexts[0] + else: + self._context = self._browser.new_context(viewport=viewport) + + pages = self._context.pages + self._page = pages[0] if pages else self._context.new_page() + self._wire_close_listeners() + + def _wire_close_listeners(self): + """Mark needs_restart whenever the browser / context / page dies externally.""" + def _on_dead(_obj=None): + self._needs_restart = True + + try: + if self._browser: + self._browser.on("disconnected", _on_dead) + if self._context: + self._context.on("close", _on_dead) + if self._page: + self._page.on("close", _on_dead) + except Exception as e: + logger.debug(f"[Browser] Failed to wire close listeners: {e}") + + def _shutdown_browser(self): + """Shut down Playwright resources on the background thread. + + Mode-specific behavior: + - cdp: only disconnect the Playwright client; leave the user's Chrome + and its tabs untouched (do NOT close the context). + - persistent: close the persistent context (no separate browser handle). + - fresh: close context, then browser. + """ + self._cancel_idle_timer() + + if self._launch_mode == "cdp": + # For CDP, browser.close() only detaches the Playwright client; + # the user's Chrome process and its tabs stay alive. + try: + if self._browser: + self._browser.close() + except Exception as e: + logger.debug(f"[Browser] cdp disconnect error: {e}") + else: + for obj, label in [ + (self._context, "context"), + (self._browser, "browser"), + ]: + try: + if obj: + obj.close() + except Exception as e: + logger.debug(f"[Browser] {label} close error: {e}") + + try: + if self._playwright: + self._playwright.stop() + except Exception as e: + logger.debug(f"[Browser] playwright stop error: {e}") + self._page = None + self._context = None + self._browser = None + self._playwright = None + logger.info("[Browser] Browser closed") + + def _submit(self, fn: Callable, *args, **kwargs): + """Submit *fn* to the background thread and block until it completes.""" + # If the browser died externally (e.g. user closed the window), tear + # down the stale thread first so _start_thread() will relaunch fresh. + if self._needs_restart: + logger.info("[Browser] Restarting after detecting closed browser") + self.close() + self._needs_restart = False + + self._start_thread() + + if not self._alive: + raise RuntimeError("Browser is not available") + + self._reset_idle_timer() + + result_slot: Dict[str, Any] = {"event": threading.Event()} + self._task_queue.put((fn, args, kwargs, result_slot)) + + # Timeout prevents permanent hang if the background thread crashes + completed = result_slot["event"].wait(timeout=120) + if not completed: + raise TimeoutError("Browser operation timed out (120s)") + + if "error" in result_slot: + raise result_slot["error"] + return result_slot.get("value") + + # ------------------------------------------------------------------ + # Idle auto-release + # ------------------------------------------------------------------ + + def _reset_idle_timer(self): + self._cancel_idle_timer() + if self._idle_timeout > 0: + self._idle_timer = threading.Timer(self._idle_timeout, self._on_idle_timeout) + self._idle_timer.daemon = True + self._idle_timer.start() + + def _cancel_idle_timer(self): + if self._idle_timer: + self._idle_timer.cancel() + self._idle_timer = None + + def _on_idle_timeout(self): + logger.info(f"[Browser] Idle for {self._idle_timeout}s, auto-releasing browser") + self.close() + + # ------------------------------------------------------------------ + # Public lifecycle + # ------------------------------------------------------------------ + + def close(self): + """Shut down browser and background thread (safe from any thread).""" + self._cancel_idle_timer() + with self._lock: + if not self._alive: + self._needs_restart = False + return + self._alive = False + t = self._thread + if self._task_queue is not None: + self._task_queue.put(None) + if t is not None and t.is_alive(): + t.join(timeout=10) + with self._lock: + self._thread = None + self._needs_restart = False + + # ------------------------------------------------------------------ + # Actions (each method is dispatched to the background thread) + # ------------------------------------------------------------------ + + def navigate(self, url: str, timeout: int = 30000) -> Dict[str, Any]: + return self._submit(self._do_navigate, url, timeout) + + def _do_navigate(self, url: str, timeout: int) -> Dict[str, Any]: + page = self._page + try: + resp = page.goto(url, wait_until="domcontentloaded", timeout=timeout) + status = resp.status if resp else None + except Exception as e: + return {"error": f"Navigation failed: {e}"} + + try: + page.wait_for_load_state("networkidle", timeout=8000) + except Exception: + pass + page.wait_for_timeout(500) + + try: + title = page.title() + except Exception: + title = "" + try: + current_url = page.url + except Exception: + current_url = url + + return {"url": current_url, "title": title, "status": status} + + def snapshot(self, selector: Optional[str] = None) -> str: + return self._submit(self._do_snapshot, selector) + + def _do_snapshot(self, selector: Optional[str] = None) -> str: + page = self._page + try: + result = page.evaluate(_SNAPSHOT_JS) + except Exception as e: + return f"[Snapshot error: {e}]" + + tree = result.get("tree") + ref_count = result.get("refCount", 0) + lines = _flatten_tree(tree) + + try: + title = page.title() + except Exception: + title = "" + try: + url = page.url + except Exception: + url = "" + + header = f"Page: {title} ({url})\nInteractive elements: {ref_count}\n---" + body = "\n".join(lines) + + max_chars = self._config.get("snapshot_max_chars", 30000) + if len(body) > max_chars: + body = body[:max_chars] + "\n... [snapshot truncated]" + + return f"{header}\n{body}" + + def screenshot(self, full_page: bool = False, cwd: str = "") -> str: + return self._submit(self._do_screenshot, full_page, cwd) + + def _do_screenshot(self, full_page: bool = False, cwd: str = "") -> str: + page = self._page + save_dir = self._get_screenshot_dir(cwd) + filename = f"screenshot_{uuid.uuid4().hex[:8]}.png" + filepath = os.path.join(save_dir, filename) + page.screenshot(path=filepath, full_page=full_page) + logger.info(f"[Browser] Screenshot saved: {filepath}") + return filepath + + def click(self, ref: Optional[int] = None, selector: Optional[str] = None, + timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_click, ref, selector, timeout) + + def _do_click(self, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }}; + el.click(); + return {{ clicked: true, tag: el.tagName.toLowerCase() }}; + }} + """) + if result.get("error"): + return result + page.wait_for_timeout(500) + return result + elif selector: + page.click(selector, timeout=timeout) + return {"clicked": True, "selector": selector} + else: + return {"error": "Provide either ref (from snapshot) or selector"} + except Exception as e: + return {"error": f"Click failed: {e}"} + + def fill(self, text: str, ref: Optional[int] = None, + selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_fill, text, ref, selector, timeout) + + def _do_fill(self, text, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el) return {{ error: "ref {ref} not found. Run snapshot first." }}; + el.focus(); + el.value = ""; + return {{ tag: el.tagName.toLowerCase(), name: el.name || "" }}; + }} + """) + if result.get("error"): + return result + page.keyboard.type(text) + return {"filled": True, "ref": ref, "text": text} + elif selector: + page.fill(selector, text, timeout=timeout) + return {"filled": True, "selector": selector, "text": text} + else: + return {"error": "Provide either ref (from snapshot) or selector"} + except Exception as e: + return {"error": f"Fill failed: {e}"} + + def select(self, value: str, ref: Optional[int] = None, + selector: Optional[str] = None, timeout: int = 5000) -> Dict[str, Any]: + return self._submit(self._do_select, value, ref, selector, timeout) + + def _do_select(self, value, ref, selector, timeout) -> Dict[str, Any]: + page = self._page + try: + if ref is not None: + result = page.evaluate(f""" + () => {{ + const el = window.__cowRefMap && window.__cowRefMap[{ref}]; + if (!el || el.tagName.toLowerCase() !== "select") + return {{ error: "ref {ref} is not a + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +
+ + + + +
+ +
+ +
+ CowAgent +

CowAgent

+

我可以帮你解答问题、管理计算机、创造和执行技能,并通过
长期记忆和知识库不断成长

+ +
+
+
+
+ +
+ 系统管理 +
+

查看工作空间里有哪些文件

+
+
+
+
+ +
+ 定时任务 +
+

1分钟后提醒我检查服务器

+
+
+
+
+ +
+ 编程助手 +
+

搜索AI资讯并生成可视化网页报告

+
+
+
+
+ +
+ 知识库 +
+

查看知识库当前文档情况

+
+
+
+
+ +
+ 技能系统 +
+

查看所有支持的工具和技能

+
+
+
+
+ +
+ 指令中心 +
+

查看全部命令

+
+
+
+
+ + + + + +
+
+ + +
+
+ + + +
+ + + + +
+ + +
+ +
+
+
+
+ + + + +
+
+
+
+
+

配置管理

+

管理模型和 Agent 配置

+
+
+
+ + +
+
+
+ +
+

模型配置

+ + 高级配置 + + +
+
+ +
+ +
+
+ -- + +
+
+
+ +
+ +
+ +
+
+ -- + +
+
+
+ +
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ + +
+
+
+ +
+

Agent 配置

+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ +
+

安全设置

+
+
+
+ + +

留空则不启用密码保护

+
+
+ + +
+
+
+ + +
+
+
+ +
+

语言

+
+
+
+ +
+
+ -- + +
+
+
+
+
+
+ +
+
+
+
+ + + + +
+
+
+
+
+

技能管理

+

查看、启用或禁用 Agent 技能

+
+ + + 探索技能广场 + +
+ + +
+
+ 内置工具 + +
+
+ + 加载工具中... +
+ +
+ + +
+
+ 技能 + +
+
+
+ +
+

加载技能中...

+

技能加载后将显示在此处

+
+
+
+
+
+
+ + + + +
+
+
+ + +
+
+
+

记忆管理

+

查看 Agent 记忆文件和内容

+
+
+ + +
+
+
+
+ +
+

加载记忆文件中...

+

记忆文件将显示在此处

+
+ +
+ + + + +
+
+
+ + + + +
+
+
+ + +
+
+

知识库

+

浏览和探索你的知识库

+
+
+ + +
+ + +
+
+ + +
+ +
+
+ + +
+
+ +
+

加载知识库中...

+

知识页面将显示在这里

+ +
+ + + + + + + +
+
+
+ + + + +
+ + +
+
+
+
+

模型管理

+

统一管理对话、视觉、语音、向量、图像、搜索能力

+
+ +
+
+ Loading... +
+ +
+
+
+ + + + +
+
+
+
+
+

通道管理

+

管理已接入的消息通道

+
+ +
+
+ +
+
+
+ + + + +
+
+
+
+
+

定时任务

+

查看和管理定时任务

+
+
+ +
+
+
+
+ +
+

Loading...

+
+ +
+
+
+ + + + +
+
+
+
+
+

日志

+

实时日志输出 (run.log)

+
+
+ +
+
+
+ + + +
+ run.log +
+
+ + + + + +
+
+ + 实时 +
+
+
+

日志流即将在此提供。将连接 run.log 实现类似 tail -f 的实时输出。

+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + + + diff --git a/channel/web/static/axios.min.js b/channel/web/static/axios.min.js new file mode 100644 index 0000000..79aa153 --- /dev/null +++ b/channel/web/static/axios.min.js @@ -0,0 +1,2 @@ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).axios=t()}(this,(function(){"use strict";function e(t){return e="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e},e(t)}function t(e,t){if(!(e instanceof t))throw new TypeError("Cannot call a class as a function")}function n(e,t){for(var n=0;n2&&void 0!==arguments[2]?arguments[2]:{},s=i.allOwnKeys,a=void 0!==s&&s;if(null!=t)if("object"!==e(t)&&(t=[t]),l(t))for(r=0,o=t.length;r3&&void 0!==arguments[3]?arguments[3]:{},i=r.allOwnKeys;return S(t,(function(t,r){n&&m(t)?e[r]=o(t,n):e[r]=t}),{allOwnKeys:i}),e},trim:function(e){return e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"")},stripBOM:function(e){return 65279===e.charCodeAt(0)&&(e=e.slice(1)),e},inherits:function(e,t,n,r){e.prototype=Object.create(t.prototype,r),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},toFlatObject:function(e,t,n,r){var o,i,s,u={};if(t=t||{},null==e)return t;do{for(i=(o=Object.getOwnPropertyNames(e)).length;i-- >0;)s=o[i],r&&!r(s,e,t)||u[s]||(t[s]=e[s],u[s]=!0);e=!1!==n&&a(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},kindOf:u,kindOfTest:c,endsWith:function(e,t,n){e=String(e),(void 0===n||n>e.length)&&(n=e.length),n-=t.length;var r=e.indexOf(t,n);return-1!==r&&r===n},toArray:function(e){if(!e)return null;if(l(e))return e;var t=e.length;if(!v(t))return null;for(var n=new Array(t);t-- >0;)n[t]=e[t];return n},forEachEntry:function(e,t){for(var n,r=(e&&e[Symbol.iterator]).call(e);(n=r.next())&&!n.done;){var o=n.value;t.call(e,o[0],o[1])}},matchAll:function(e,t){for(var n,r=[];null!==(n=e.exec(t));)r.push(n);return r},isHTMLForm:T,hasOwnProperty:x,hasOwnProp:x,reduceDescriptors:N,freezeMethods:function(e){N(e,(function(t,n){var r=e[n];m(r)&&(t.enumerable=!1,"writable"in t?t.writable=!1:t.set||(t.set=function(){throw Error("Can not read-only method '"+n+"'")}))}))},toObjectSet:function(e,t){var n={},r=function(e){e.forEach((function(e){n[e]=!0}))};return l(e)?r(e):r(String(e).split(t)),n},toCamelCase:function(e){return e.toLowerCase().replace(/[_-\s]([a-z\d])(\w*)/g,(function(e,t,n){return t.toUpperCase()+n}))},noop:function(){},toFiniteNumber:function(e,t){return e=+e,Number.isFinite(e)?e:t}};function _(e,t,n,r,o){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=(new Error).stack,this.message=e,this.name="AxiosError",t&&(this.code=t),n&&(this.config=n),r&&(this.request=r),o&&(this.response=o)}P.inherits(_,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:this.config,code:this.code,status:this.response&&this.response.status?this.response.status:null}}});var B=_.prototype,D={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach((function(e){D[e]={value:e}})),Object.defineProperties(_,D),Object.defineProperty(B,"isAxiosError",{value:!0}),_.from=function(e,t,n,r,o,i){var s=Object.create(B);return P.toFlatObject(e,s,(function(e){return e!==Error.prototype}),(function(e){return"isAxiosError"!==e})),_.call(s,e.message,t,n,r,o),s.cause=e,s.name=e.name,i&&Object.assign(s,i),s};var F="object"==("undefined"==typeof self?"undefined":e(self))?self.FormData:window.FormData;function U(e){return P.isPlainObject(e)||P.isArray(e)}function k(e){return P.endsWith(e,"[]")?e.slice(0,-2):e}function L(e,t,n){return e?e.concat(t).map((function(e,t){return e=k(e),!n&&t?"["+e+"]":e})).join(n?".":""):t}var q=P.toFlatObject(P,{},null,(function(e){return/^is[A-Z]/.test(e)}));function z(t,n,r){if(!P.isObject(t))throw new TypeError("target must be an object");n=n||new(F||FormData);var o,i=(r=P.toFlatObject(r,{metaTokens:!0,dots:!1,indexes:!1},!1,(function(e,t){return!P.isUndefined(t[e])}))).metaTokens,s=r.visitor||l,a=r.dots,u=r.indexes,c=(r.Blob||"undefined"!=typeof Blob&&Blob)&&((o=n)&&P.isFunction(o.append)&&"FormData"===o[Symbol.toStringTag]&&o[Symbol.iterator]);if(!P.isFunction(s))throw new TypeError("visitor must be a function");function f(e){if(null===e)return"";if(P.isDate(e))return e.toISOString();if(!c&&P.isBlob(e))throw new _("Blob is not supported. Use a Buffer instead.");return P.isArrayBuffer(e)||P.isTypedArray(e)?c&&"function"==typeof Blob?new Blob([e]):Buffer.from(e):e}function l(t,r,o){var s=t;if(t&&!o&&"object"===e(t))if(P.endsWith(r,"{}"))r=i?r:r.slice(0,-2),t=JSON.stringify(t);else if(P.isArray(t)&&function(e){return P.isArray(e)&&!e.some(U)}(t)||P.isFileList(t)||P.endsWith(r,"[]")&&(s=P.toArray(t)))return r=k(r),s.forEach((function(e,t){!P.isUndefined(e)&&n.append(!0===u?L([r],t,a):null===u?r:r+"[]",f(e))})),!1;return!!U(t)||(n.append(L(o,r,a),f(t)),!1)}var d=[],h=Object.assign(q,{defaultVisitor:l,convertValue:f,isVisitable:U});if(!P.isObject(t))throw new TypeError("data must be an object");return function e(t,r){if(!P.isUndefined(t)){if(-1!==d.indexOf(t))throw Error("Circular reference detected in "+r.join("."));d.push(t),P.forEach(t,(function(t,o){!0===(!P.isUndefined(t)&&s.call(n,t,P.isString(o)?o.trim():o,r,h))&&e(t,r?r.concat(o):[o])})),d.pop()}}(t),n}function I(e){var t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,(function(e){return t[e]}))}function M(e,t){this._pairs=[],e&&z(e,this,t)}var J=M.prototype;function H(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+").replace(/%5B/gi,"[").replace(/%5D/gi,"]")}function V(e,t,n){if(!t)return e;var r=e.indexOf("#");-1!==r&&(e=e.slice(0,r));var o=n&&n.encode||H,i=P.isURLSearchParams(t)?t.toString():new M(t,n).toString(o);return i&&(e+=(-1===e.indexOf("?")?"?":"&")+i),e}J.append=function(e,t){this._pairs.push([e,t])},J.toString=function(e){var t=e?function(t){return e.call(this,t,I)}:I;return this._pairs.map((function(e){return t(e[0])+"="+t(e[1])}),"").join("&")};var W,K=function(){function e(){t(this,e),this.handlers=[]}return r(e,[{key:"use",value:function(e,t,n){return this.handlers.push({fulfilled:e,rejected:t,synchronous:!!n&&n.synchronous,runWhen:n?n.runWhen:null}),this.handlers.length-1}},{key:"eject",value:function(e){this.handlers[e]&&(this.handlers[e]=null)}},{key:"clear",value:function(){this.handlers&&(this.handlers=[])}},{key:"forEach",value:function(e){P.forEach(this.handlers,(function(t){null!==t&&e(t)}))}}]),e}(),X={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},$="undefined"!=typeof URLSearchParams?URLSearchParams:M,Q=FormData,G=("undefined"==typeof navigator||"ReactNative"!==(W=navigator.product)&&"NativeScript"!==W&&"NS"!==W)&&"undefined"!=typeof window&&"undefined"!=typeof document,Y={isBrowser:!0,classes:{URLSearchParams:$,FormData:Q,Blob:Blob},isStandardBrowserEnv:G,protocols:["http","https","file","blob","url","data"]};function Z(e){function t(e,n,r,o){var i=e[o++],s=Number.isFinite(+i),a=o>=e.length;return i=!i&&P.isArray(r)?r.length:i,a?(P.hasOwnProp(r,i)?r[i]=[r[i],n]:r[i]=n,!s):(r[i]&&P.isObject(r[i])||(r[i]=[]),t(e,n,r[i],o)&&P.isArray(r[i])&&(r[i]=function(e){var t,n,r={},o=Object.keys(e),i=o.length;for(t=0;t0;)if(t===(n=r[o]).toLowerCase())return n;return null}function le(e,t){e&&this.set(e),this[se]=t||null}function de(e,t){var n=0,r=function(e,t){e=e||10;var n,r=new Array(e),o=new Array(e),i=0,s=0;return t=void 0!==t?t:1e3,function(a){var u=Date.now(),c=o[s];n||(n=u),r[i]=a,o[i]=u;for(var f=s,l=0;f!==i;)l+=r[f++],f%=e;if((i=(i+1)%e)===s&&(s=(s+1)%e),!(u-n-1,i=P.isObject(e);if(i&&P.isHTMLForm(e)&&(e=new FormData(e)),P.isFormData(e))return o&&o?JSON.stringify(Z(e)):e;if(P.isArrayBuffer(e)||P.isBuffer(e)||P.isStream(e)||P.isFile(e)||P.isBlob(e))return e;if(P.isArrayBufferView(e))return e.buffer;if(P.isURLSearchParams(e))return t.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),e.toString();if(i){if(r.indexOf("application/x-www-form-urlencoded")>-1)return function(e,t){return z(e,new Y.classes.URLSearchParams,Object.assign({visitor:function(e,t,n,r){return Y.isNode&&P.isBuffer(e)?(this.append(t,e.toString("base64")),!1):r.defaultVisitor.apply(this,arguments)}},t))}(e,this.formSerializer).toString();if((n=P.isFileList(e))||r.indexOf("multipart/form-data")>-1){var s=this.env&&this.env.FormData;return z(n?{"files[]":e}:e,s&&new s,this.formSerializer)}}return i||o?(t.setContentType("application/json",!1),function(e,t,n){if(P.isString(e))try{return(t||JSON.parse)(e),P.trim(e)}catch(e){if("SyntaxError"!==e.name)throw e}return(n||JSON.stringify)(e)}(e)):e}],transformResponse:[function(e){var t=this.transitional||be.transitional,n=t&&t.forcedJSONParsing,r="json"===this.responseType;if(e&&P.isString(e)&&(n&&!this.responseType||r)){var o=!(t&&t.silentJSONParsing)&&r;try{return JSON.parse(e)}catch(e){if(o){if("SyntaxError"===e.name)throw _.from(e,_.ERR_BAD_RESPONSE,this,null,this.response);throw e}}}return e}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Y.classes.FormData,Blob:Y.classes.Blob},validateStatus:function(e){return e>=200&&e<300},headers:{common:{Accept:"application/json, text/plain, */*"}}};function ge(e,t){var n=this||be,r=t||n,o=le.from(r.headers),i=r.data;return P.forEach(e,(function(e){i=e.call(n,i,o.normalize(),t?t.status:void 0)})),o.normalize(),i}function Ee(e){return!(!e||!e.__CANCEL__)}function we(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new re}function Oe(e){return we(e),e.headers=le.from(e.headers),e.data=ge.call(e,e.transformRequest),(e.adapter||be.adapter)(e).then((function(t){return we(e),t.data=ge.call(e,e.transformResponse,t),t.headers=le.from(t.headers),t}),(function(t){return Ee(t)||(we(e),t&&t.response&&(t.response.data=ge.call(e,e.transformResponse,t.response),t.response.headers=le.from(t.response.headers))),Promise.reject(t)}))}function Re(e,t){t=t||{};var n={};function r(e,t){return P.isPlainObject(e)&&P.isPlainObject(t)?P.merge(e,t):P.isPlainObject(t)?P.merge({},t):P.isArray(t)?t.slice():t}function o(n){return P.isUndefined(t[n])?P.isUndefined(e[n])?void 0:r(void 0,e[n]):r(e[n],t[n])}function i(e){if(!P.isUndefined(t[e]))return r(void 0,t[e])}function s(n){return P.isUndefined(t[n])?P.isUndefined(e[n])?void 0:r(void 0,e[n]):r(void 0,t[n])}function a(n){return n in t?r(e[n],t[n]):n in e?r(void 0,e[n]):void 0}var u={url:i,method:i,data:i,baseURL:s,transformRequest:s,transformResponse:s,paramsSerializer:s,timeout:s,timeoutMessage:s,withCredentials:s,adapter:s,responseType:s,xsrfCookieName:s,xsrfHeaderName:s,onUploadProgress:s,onDownloadProgress:s,decompress:s,maxContentLength:s,maxBodyLength:s,beforeRedirect:s,transport:s,httpAgent:s,httpsAgent:s,cancelToken:s,socketPath:s,responseEncoding:s,validateStatus:a};return P.forEach(Object.keys(e).concat(Object.keys(t)),(function(e){var t=u[e]||o,r=t(e);P.isUndefined(r)&&t!==a||(n[e]=r)})),n}P.forEach(["delete","get","head"],(function(e){be.headers[e]={}})),P.forEach(["post","put","patch"],(function(e){be.headers[e]=P.merge(ve)}));var Se="1.1.2",Ae={};["object","boolean","number","function","string","symbol"].forEach((function(t,n){Ae[t]=function(r){return e(r)===t||"a"+(n<1?"n ":" ")+t}}));var je={};Ae.transitional=function(e,t,n){function r(e,t){return"[Axios v1.1.2] Transitional option '"+e+"'"+t+(n?". "+n:"")}return function(n,o,i){if(!1===e)throw new _(r(o," has been removed"+(t?" in "+t:"")),_.ERR_DEPRECATED);return t&&!je[o]&&(je[o]=!0,console.warn(r(o," has been deprecated since v"+t+" and will be removed in the near future"))),!e||e(n,o,i)}};var Te={assertOptions:function(t,n,r){if("object"!==e(t))throw new _("options must be an object",_.ERR_BAD_OPTION_VALUE);for(var o=Object.keys(t),i=o.length;i-- >0;){var s=o[i],a=n[s];if(a){var u=t[s],c=void 0===u||a(u,s,t);if(!0!==c)throw new _("option "+s+" must be "+c,_.ERR_BAD_OPTION_VALUE)}else if(!0!==r)throw new _("Unknown option "+s,_.ERR_BAD_OPTION)}},validators:Ae},xe=Te.validators,Ce=function(){function e(n){t(this,e),this.defaults=n,this.interceptors={request:new K,response:new K}}return r(e,[{key:"request",value:function(e,t){"string"==typeof e?(t=t||{}).url=e:t=e||{};var n=(t=Re(this.defaults,t)).transitional;void 0!==n&&Te.assertOptions(n,{silentJSONParsing:xe.transitional(xe.boolean),forcedJSONParsing:xe.transitional(xe.boolean),clarifyTimeoutError:xe.transitional(xe.boolean)},!1),t.method=(t.method||this.defaults.method||"get").toLowerCase();var r=t.headers&&P.merge(t.headers.common,t.headers[t.method]);r&&P.forEach(["delete","get","head","post","put","patch","common"],(function(e){delete t.headers[e]})),t.headers=new le(t.headers,r);var o=[],i=!0;this.interceptors.request.forEach((function(e){"function"==typeof e.runWhen&&!1===e.runWhen(t)||(i=i&&e.synchronous,o.unshift(e.fulfilled,e.rejected))}));var s,a=[];this.interceptors.response.forEach((function(e){a.push(e.fulfilled,e.rejected)}));var u,c=0;if(!i){var f=[Oe.bind(this),void 0];for(f.unshift.apply(f,o),f.push.apply(f,a),u=f.length,s=Promise.resolve(t);c0;)o._listeners[t](e);o._listeners=null}})),this.promise.then=function(e){var t,n=new Promise((function(e){o.subscribe(e),t=e})).then(e);return n.cancel=function(){o.unsubscribe(t)},n},n((function(e,t,n){o.reason||(o.reason=new re(e,t,n),r(o.reason))}))}return r(e,[{key:"throwIfRequested",value:function(){if(this.reason)throw this.reason}},{key:"subscribe",value:function(e){this.reason?e(this.reason):this._listeners?this._listeners.push(e):this._listeners=[e]}},{key:"unsubscribe",value:function(e){if(this._listeners){var t=this._listeners.indexOf(e);-1!==t&&this._listeners.splice(t,1)}}}],[{key:"source",value:function(){var t;return{token:new e((function(e){t=e})),cancel:t}}}]),e}();var Pe=function e(t){var n=new Ce(t),r=o(Ce.prototype.request,n);return P.extend(r,Ce.prototype,n,{allOwnKeys:!0}),P.extend(r,n,null,{allOwnKeys:!0}),r.create=function(n){return e(Re(t,n))},r}(be);return Pe.Axios=Ce,Pe.CanceledError=re,Pe.CancelToken=Ne,Pe.isCancel=Ee,Pe.VERSION=Se,Pe.toFormData=z,Pe.AxiosError=_,Pe.Cancel=Pe.CanceledError,Pe.all=function(e){return Promise.all(e)},Pe.spread=function(e){return function(t){return e.apply(null,t)}},Pe.isAxiosError=function(e){return P.isObject(e)&&!0===e.isAxiosError},Pe.formToJSON=function(e){return Z(P.isHTMLForm(e)?new FormData(e):e)},Pe})); +//# sourceMappingURL=axios.min.js.map diff --git a/channel/web/static/css/console.css b/channel/web/static/css/console.css new file mode 100644 index 0000000..3051976 --- /dev/null +++ b/channel/web/static/css/console.css @@ -0,0 +1,1685 @@ +/* ===================================================================== + CowAgent Console Styles + ===================================================================== */ + +/* Animations */ +@keyframes pulseDot { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } + 40% { transform: scale(1); opacity: 1; } +} + +/* Scrollbar */ +* { scrollbar-width: thin; scrollbar-color: #94a3b8 transparent; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: #94a3b8; border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: #64748b; } +.dark ::-webkit-scrollbar-thumb { background: #475569; } +.dark ::-webkit-scrollbar-thumb:hover { background: #64748b; } + +/* Generic Tooltip (via data-tooltip attribute) */ +[data-tooltip] { + position: relative; +} +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + left: 50%; + bottom: calc(100% + 8px); + transform: translateX(-50%); + padding: 5px 10px; + border-radius: 6px; + font-size: 12px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; + background: #1e293b; + color: #e2e8f0; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; + z-index: 100; +} +[data-tooltip-pos="bottom"]::after { + bottom: auto; + top: calc(100% + 8px); +} +.dark [data-tooltip]::after { + background: #334155; + color: #f1f5f9; +} +[data-tooltip]:hover::after { + opacity: 1; +} +[data-tooltip=""]:hover::after { + display: none; +} + +/* Sidebar */ +.sidebar-item.active { + background: rgba(255, 255, 255, 0.08); + color: #FFFFFF; +} +.sidebar-item.active .item-icon { color: #4ABE6E; } + +/* Session Panel */ +.session-panel { + width: 220px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: #fafafa; + border-right: 1px solid #e5e7eb; + height: 100vh; + overflow: hidden; + transition: width 0.2s ease; +} +.dark .session-panel { + background: #111111; + border-right-color: rgba(255, 255, 255, 0.08); +} +.session-panel.hidden { display: none; } +.session-panel-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #e5e7eb; + flex-shrink: 0; +} +.dark .session-panel-header { border-bottom-color: rgba(255, 255, 255, 0.08); } +.session-panel-title { + font-size: 14px; + font-weight: 600; + color: #374151; +} +.dark .session-panel-title { color: #d1d5db; } +.session-panel-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + border: none; + background: none; + color: #9ca3af; + cursor: pointer; + transition: background 0.15s, color 0.15s; + font-size: 12px; +} +.session-panel-close:hover { + background: #f3f4f6; + color: #374151; +} +.dark .session-panel-close:hover { + background: rgba(255, 255, 255, 0.08); + color: #e5e5e5; +} +.session-panel-new { + display: flex; + align-items: center; + gap: 8px; + margin: 10px 12px; + padding: 8px 14px; + border-radius: 8px; + border: 1px dashed #d1d5db; + background: none; + color: #6b7280; + font-size: 13px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s, background 0.15s; + flex-shrink: 0; +} +.session-panel-new:hover { + border-color: #9ca3af; + color: #374151; + background: #f9fafb; +} +.dark .session-panel-new { + border-color: rgba(255, 255, 255, 0.12); + color: #9ca3af; +} +.dark .session-panel-new:hover { + border-color: rgba(255, 255, 255, 0.25); + color: #e5e5e5; + background: rgba(255, 255, 255, 0.04); +} + +/* Session List */ +.session-list { + flex: 1; + overflow-y: auto; + padding: 4px 8px; + scrollbar-width: none; +} +.session-list:hover { scrollbar-width: thin; } +.session-list::-webkit-scrollbar { width: 4px; background: transparent; } +.session-list::-webkit-scrollbar-thumb { background: transparent; border-radius: 2px; } +.session-list:hover::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.2); } +.dark .session-list:hover::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.15); } +.session-group-label { + padding: 10px 8px 4px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #9ca3af; +} +.dark .session-group-label { color: #525252; } +.session-empty { + padding: 20px 12px; + text-align: center; + font-size: 13px; + color: #9ca3af; +} +.session-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin: 1px 0; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s, color 0.15s; + color: #6b7280; + font-size: 13px; + position: relative; +} +.dark .session-item { color: #a3a3a3; } +.session-item:hover { + background: #f3f4f6; + color: #111827; +} +.dark .session-item:hover { + background: rgba(255, 255, 255, 0.05); + color: #e5e5e5; +} +.session-item.active { + background: #e5e7eb; + color: #111827; +} +.dark .session-item.active { + background: rgba(255, 255, 255, 0.1); + color: #ffffff; +} +.session-icon { + flex-shrink: 0; + font-size: 11px; + color: #9ca3af; + width: 16px; + text-align: center; +} +.dark .session-icon { color: #525252; } +.session-item.active .session-icon { color: #4ABE6E; } +.session-title { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.session-delete { + flex-shrink: 0; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 5px; + font-size: 10px; + color: #9ca3af; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + cursor: pointer; + background: none; + border: none; + padding: 0; +} +.session-item:hover .session-delete { opacity: 1; } +.session-delete:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.1); +} +.dark .session-delete:hover { background: rgba(239, 68, 68, 0.15); } + +/* Rename button: shares the look of the delete button, sits to its left. + Negative right margin tightens the gap to the delete button only. */ +.session-rename { + flex-shrink: 0; + margin-right: -6px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + color: #9ca3af; + font-size: 11px; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; + cursor: pointer; + background: none; + border: none; + padding: 0; +} +.session-item:hover .session-rename { opacity: 1; } +.session-rename:hover { + color: #4ABE6E; + background: rgba(74, 190, 110, 0.12); +} +.dark .session-rename:hover { background: rgba(74, 190, 110, 0.18); } + +/* Inline title editor */ +.session-title-input { + flex: 1; + min-width: 0; + font-size: 13px; + font-family: inherit; + color: #111827; + background: #ffffff; + border: 1px solid #4ABE6E; + border-radius: 6px; + padding: 2px 6px; + outline: none; +} +.dark .session-title-input { + color: #e5e5e5; + background: rgba(255, 255, 255, 0.06); + border-color: #4ABE6E; +} + +/* Context Divider */ +.context-divider { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 24px; + color: #9ca3af; +} +.context-divider::before, .context-divider::after { + content: ''; + flex: 1; + height: 1px; + background: linear-gradient(to right, transparent, #d1d5db, transparent); +} +.dark .context-divider::before, .dark .context-divider::after { + background: linear-gradient(to right, transparent, rgba(255,255,255,0.12), transparent); +} +.context-divider span { + font-size: 12px; + white-space: nowrap; + color: #9ca3af; +} + +/* Confirm Modal */ +.confirm-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 0.2s ease; +} +.confirm-overlay.visible { opacity: 1; } +.confirm-modal { + background: #fff; + border-radius: 14px; + width: 380px; + max-width: 90vw; + padding: 28px 24px 20px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18); + transform: scale(0.92); + transition: transform 0.2s ease; +} +.confirm-overlay.visible .confirm-modal { transform: scale(1); } +.dark .confirm-modal { + background: #1e1e1e; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +.confirm-title { + font-size: 16px; + font-weight: 600; + color: #1f2937; + margin-bottom: 8px; +} +.dark .confirm-title { color: #e5e7eb; } +.confirm-message { + font-size: 14px; + color: #6b7280; + line-height: 1.5; + margin-bottom: 24px; +} +.dark .confirm-message { color: #9ca3af; } +.confirm-actions { + display: flex; + justify-content: flex-end; + gap: 10px; +} +.confirm-btn { + padding: 8px 20px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + border: none; + transition: all 0.15s ease; +} +.confirm-btn-cancel { + background: #f3f4f6; + color: #374151; +} +.confirm-btn-cancel:hover { background: #e5e7eb; } +.dark .confirm-btn-cancel { + background: rgba(255, 255, 255, 0.08); + color: #d1d5db; +} +.dark .confirm-btn-cancel:hover { background: rgba(255, 255, 255, 0.14); } +.confirm-btn-ok { + background: #ef4444; + color: #fff; +} +.confirm-btn-ok:hover { background: #dc2626; } + +/* Session panel overlay (mobile only, click to close) */ +.session-panel-overlay { + display: none; +} +@media (max-width: 768px) { + .session-panel-overlay { + display: block; + position: fixed; + inset: 0; + z-index: 44; + background: rgba(0, 0, 0, 0.3); + } + .session-panel-overlay.hidden { + display: none; + } +} + +/* Mobile: session panel as overlay */ +@media (max-width: 768px) { + .session-panel { + position: fixed; + top: 0; + left: 0; + z-index: 45; + width: 220px; + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15); + } + .dark .session-panel { + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.4); + } +} + +/* Menu Groups */ +.menu-group-items { max-height: 0; overflow: hidden; transition: max-height 0.25s ease-out; } +.menu-group.open .menu-group-items { max-height: 2000px; transition: max-height 0.35s ease-in; } +.menu-group .chevron { transition: transform 0.25s ease; } +.menu-group.open .chevron { transform: rotate(90deg); } + +/* View Switching */ +.view { display: none; height: 100%; } +.view.active { display: flex; flex-direction: column; } + +/* Markdown Content */ +.msg-content p { margin: 0.5em 0; line-height: 1.7; } +.msg-content p:first-child { margin-top: 0; } +.msg-content p:last-child { margin-bottom: 0; } +.msg-content h1, .msg-content h2, .msg-content h3, +.msg-content h4, .msg-content h5, .msg-content h6 { + margin-top: 1.2em; margin-bottom: 0.6em; font-weight: 600; line-height: 1.3; +} +.msg-content h1 { font-size: 1.4em; } +.msg-content h2 { font-size: 1.25em; } +.msg-content h3 { font-size: 1.1em; } +.msg-content ul { margin: 0.5em 0; padding-left: 1.8em; list-style: disc; } +.msg-content ol { margin: 0.5em 0; padding-left: 1.8em; list-style: decimal; } +.msg-content li { margin: 0.25em 0; } +.msg-content pre { + border-radius: 8px; overflow-x: auto; margin: 0.8em 0; + background: #f1f5f9; padding: 1em; +} +.dark .msg-content pre { background: #111111; } +.msg-content code { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.875em; +} +.msg-content :not(pre) > code { + background: rgba(74, 190, 110, 0.1); color: #1C6B3B; + padding: 2px 6px; border-radius: 4px; +} +.dark .msg-content :not(pre) > code { + background: rgba(74, 190, 110, 0.15); color: #74E9A4; +} +.msg-content pre code { background: transparent; padding: 0; color: inherit; } +.msg-content blockquote { + border-left: 3px solid #4ABE6E; padding: 0.5em 1em; + margin: 0.8em 0; background: rgba(74, 190, 110, 0.05); border-radius: 0 6px 6px 0; +} +.dark .msg-content blockquote { background: rgba(74, 190, 110, 0.08); } +.msg-content table { border-collapse: collapse; width: 100%; margin: 0.8em 0; } +.msg-content th, .msg-content td { + border: 1px solid #e2e8f0; padding: 8px 12px; text-align: left; +} +.dark .msg-content th, .dark .msg-content td { border-color: rgba(255,255,255,0.1); } +.msg-content th { background: #f1f5f9; font-weight: 600; } +.dark .msg-content th { background: #111111; } +.msg-content img { max-width: 100%; height: auto; border-radius: 8px; margin: 0.5em 0; } +.msg-content a { color: #35A85B; text-decoration: underline; } +.msg-content a:hover { color: #228547; } + +/* Overrides for user bubble (white text on green bg) */ +.user-bubble.msg-content a { color: #ffffff !important; text-decoration: underline; text-decoration-color: rgba(255,255,255,0.6); } +.user-bubble.msg-content a:hover { color: #e0f5e8 !important; text-decoration-color: #e0f5e8; } +.user-bubble.msg-content :not(pre) > code { background: rgba(255,255,255,0.2); color: #ffffff; } +.msg-content hr { border: none; height: 1px; background: #e2e8f0; margin: 1.2em 0; } +.dark .msg-content hr { background: rgba(255,255,255,0.1); } + +/* SSE Streaming cursor */ +@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } +.sse-streaming::after { + content: '▋'; + display: inline-block; + margin-left: 2px; + color: #4ABE6E; + animation: blink 0.9s step-end infinite; + font-size: 0.85em; + vertical-align: middle; +} + +/* Agent steps (thinking summaries + tool indicators) */ +.agent-steps:empty { display: none; } +.agent-steps:not(:empty) { + margin-bottom: 0.625rem; + padding-bottom: 0.5rem; + border-bottom: 1px dashed rgba(0, 0, 0, 0.08); +} +.dark .agent-steps:not(:empty) { border-bottom-color: rgba(255, 255, 255, 0.08); } + +.agent-step { + font-size: 0.75rem; + line-height: 1.4; + color: #94a3b8; + margin-bottom: 0.25rem; +} +.agent-step:last-child { margin-bottom: 0; } + +/* Thinking step - collapsible */ +.agent-thinking-step .thinking-header { + display: flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + user-select: none; +} +.agent-thinking-step .thinking-header:hover { color: #64748b; } +.dark .agent-thinking-step .thinking-header:hover { color: #cbd5e1; } +.agent-thinking-step .thinking-header i:first-child { font-size: 0.625rem; margin-top: 1px; } +.agent-thinking-step .thinking-chevron { + font-size: 0.5rem; + margin-left: auto; + transition: transform 0.2s ease; + opacity: 0.5; +} +.agent-thinking-step.expanded .thinking-chevron { transform: rotate(90deg); } +.agent-thinking-step .thinking-full { + display: none; + margin-top: 0.375rem; + margin-left: 1rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.04); + font-size: 0.75rem; + line-height: 1.5; + color: #94a3b8; + max-height: 300px; + overflow-y: auto; +} +.dark .agent-thinking-step .thinking-full { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.04); +} +.agent-thinking-step.expanded .thinking-full { display: block; } +.agent-thinking-step .thinking-full p { margin: 0.25em 0; } +.agent-thinking-step .thinking-full p:first-child { margin-top: 0; } +.agent-thinking-step .thinking-full p:last-child { margin-bottom: 0; } +.agent-thinking-step .thinking-duration { + font-size: 0.625rem; + color: #b0b8c4; + margin-bottom: 0.375rem; +} +/* Streaming reasoning: render as plain pre to avoid expensive markdown + re-parsing on every chunk. Wrap long lines so the bubble width is + respected and use the same font size/color as the rendered version. */ +.agent-thinking-step .thinking-stream-pre { + margin: 0; + padding: 0; + background: transparent; + border: 0; + font-family: inherit; + font-size: inherit; + line-height: 1.5; + color: inherit; + white-space: pre-wrap; + word-break: break-word; + overflow-wrap: anywhere; +} + +/* Content step - real text output frozen before tool calls */ +.agent-content-step { + font-size: 0.875rem; + line-height: 1.6; + color: inherit; + margin-bottom: 0.5rem; + padding-bottom: 0.5rem; + border-bottom: 1px dashed rgba(0, 0, 0, 0.06); +} +.dark .agent-content-step { border-bottom-color: rgba(255, 255, 255, 0.06); } +.agent-content-step .agent-content-body p { margin: 0.25em 0; } +.agent-content-step .agent-content-body p:first-child { margin-top: 0; } +.agent-content-step .agent-content-body p:last-child { margin-bottom: 0; } + +/* Tool step - collapsible */ +.agent-tool-step .tool-header { + display: flex; + align-items: center; + gap: 0.375rem; + cursor: pointer; + user-select: none; + padding: 1px 0; + border-radius: 4px; +} +.agent-tool-step .tool-header:hover { color: #64748b; } +.dark .agent-tool-step .tool-header:hover { color: #cbd5e1; } +.agent-tool-step .tool-icon { font-size: 0.625rem; } +.agent-tool-step .tool-chevron { + font-size: 0.5rem; + margin-left: auto; + transition: transform 0.2s ease; + opacity: 0.5; +} +.agent-tool-step.expanded .tool-chevron { transform: rotate(90deg); } +.agent-tool-step .tool-time { + font-size: 0.65rem; + opacity: 0.6; + margin-left: 0.25rem; +} + +/* Tool detail panel */ +.agent-tool-step .tool-detail { + display: none; + margin-top: 0.375rem; + margin-left: 1rem; + padding: 0.5rem; + background: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.04); +} +.dark .agent-tool-step .tool-detail { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.04); +} +.agent-tool-step.expanded .tool-detail { display: block; } +.tool-detail-section { margin-bottom: 0.375rem; } +.tool-detail-section:last-child { margin-bottom: 0; } +.tool-detail-label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.6; + margin-bottom: 0.125rem; +} +.tool-detail-content { + font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; + font-size: 0.7rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; + max-height: 200px; + overflow-y: auto; + margin: 0; + padding: 0.25rem 0; + background: transparent; + color: inherit; +} +.tool-error-text { color: #f87171; } +.tool-live-output:empty { display: none; } +.tool-streaming .tool-live-output:not(:empty)::after { + content: ' '; + display: inline-block; + width: 0.45em; + height: 1em; + margin-left: 0.15em; + vertical-align: -0.15em; + background: currentColor; + animation: tool-cursor-blink 1s steps(1) infinite; +} +@keyframes tool-cursor-blink { 50% { opacity: 0; } } + +/* Log level highlighting */ +.log-line { display: block; } +.log-line-debug { color: #94a3b8; } +.log-line-info { background-color: rgba(59, 130, 246, 0.08); } +.log-line-warning { background-color: rgba(234, 179, 8, 0.15); color: #fde68a; } +.log-line-error { background-color: rgba(239, 68, 68, 0.15); color: #fca5a5; } +.log-line-critical { background-color: rgba(239, 68, 68, 0.35); color: #ff4444; font-weight: bold; } + +/* Tool failed state */ +.agent-tool-step.tool-failed .tool-name { color: #f87171; } + +/* Config form controls */ +#view-config input[type="text"], +#view-config input[type="number"], +#view-config input[type="password"] { + height: 40px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} +#view-config input:focus { + border-color: #4ABE6E; + box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12); +} +#view-config input[type="text"]:hover, +#view-config input[type="number"]:hover, +#view-config input[type="password"]:hover { + border-color: #94a3b8; +} +.dark #view-config input[type="text"]:hover, +.dark #view-config input[type="number"]:hover, +.dark #view-config input[type="password"]:hover { + border-color: #64748b; +} + +/* Custom dropdown */ +.cfg-dropdown { + position: relative; + outline: none; +} +.cfg-dropdown-selected { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 0.75rem; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + background: #f8fafc; + font-size: 0.875rem; + color: #1e293b; + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + user-select: none; +} +.dark .cfg-dropdown-selected { + border-color: #475569; + background: rgba(255, 255, 255, 0.05); + color: #f1f5f9; +} +.cfg-dropdown-selected:hover { border-color: #94a3b8; } +.dark .cfg-dropdown-selected:hover { border-color: #64748b; } +.cfg-dropdown.open .cfg-dropdown-selected, +.cfg-dropdown:focus .cfg-dropdown-selected { + border-color: #4ABE6E; + box-shadow: 0 0 0 3px rgba(74, 190, 110, 0.12); +} +.cfg-dropdown-arrow { + font-size: 0.625rem; + color: #94a3b8; + transition: transform 0.2s ease; + flex-shrink: 0; + margin-left: 0.5rem; +} +.cfg-dropdown.open .cfg-dropdown-arrow { transform: rotate(180deg); } +.cfg-dropdown-menu { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 50; + max-height: 240px; + overflow-y: auto; + border-radius: 0.5rem; + border: 1px solid #e2e8f0; + background: #ffffff; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 4px 10px -5px rgba(0, 0, 0, 0.04); + padding: 4px; +} +.dark .cfg-dropdown-menu { + border-color: #334155; + background: #1e1e1e; + box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.4); +} +.cfg-dropdown.open .cfg-dropdown-menu { display: block; } +.cfg-dropdown-item { + display: flex; + align-items: center; + padding: 8px 10px; + border-radius: 6px; + font-size: 0.875rem; + color: #334155; + cursor: pointer; + transition: background 0.15s ease; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dark .cfg-dropdown-item { color: #cbd5e1; } +.cfg-dropdown-item:hover { background: #f1f5f9; } +.dark .cfg-dropdown-item:hover { background: rgba(255, 255, 255, 0.08); } +.cfg-dropdown-item.active { + background: rgba(74, 190, 110, 0.1); + color: #228547; + font-weight: 500; +} +.dark .cfg-dropdown-item.active { + background: rgba(74, 190, 110, 0.15); + color: #74E9A4; +} +/* When an item carries a hint (e.g. brand alias next to a technical model + id), label/hint are split into two spans so the hint sits on the right in + a dim, smaller weight. Without a hint the row stays a plain text node and + uses the default ellipsis behaviour, so no layout regressions for old call + sites. */ +.cfg-dropdown-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.cfg-dropdown-hint { + flex-shrink: 0; + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 12px; + font-weight: 400; +} +.dark .cfg-dropdown-hint { + color: #64748b; +} +.cfg-dropdown-item.active .cfg-dropdown-hint { + /* Tint the hint toward the brand colour on the active row so it doesn't + fight with the highlighted label tone. */ + color: rgba(34, 133, 71, 0.65); +} +.dark .cfg-dropdown-item.active .cfg-dropdown-hint { + color: rgba(116, 233, 164, 0.6); +} +/* The active row gets a trailing brand-green checkmark via a Font Awesome + pseudo-element so every dropdown (chat / vision / image / asr / tts / etc.) + surfaces "this is what's currently selected" without per-call JS plumbing. + When a hint is present, the ✓ sits to its right with a small gap; without + a hint, margin-left:auto pushes the ✓ flush against the right edge. */ +.cfg-dropdown-item.active::after { + content: '\f00c'; /* FontAwesome check glyph */ + font-family: 'Font Awesome 6 Free', 'Font Awesome 5 Free', 'FontAwesome'; + font-weight: 900; + margin-left: auto; + padding-left: 12px; + color: #4abe6e; + font-size: 11px; + flex-shrink: 0; +} +.cfg-dropdown-item.active:has(.cfg-dropdown-hint)::after { + /* When hint occupies the auto-margin slot, the ✓ no longer benefits + from `margin-left: auto`; replace it with a small fixed gap so the + ✓ trails the hint cleanly. */ + margin-left: 0; + padding-left: 10px; +} + +/* API Key masking via CSS (avoids browser password prompts) */ +.cfg-key-masked { + -webkit-text-security: disc; + text-security: disc; +} + +/* Provider logo image — vendors flagged as `provider-logo-invert-dark` + ship a black wordmark that disappears on the dark canvas; we invert their + luminance only in dark mode so the brand stays recognizable without + touching multi-color marks like Google/MiniMax. */ +.provider-logo-img { + object-fit: contain; + object-position: center; +} +.dark .provider-logo-invert-dark { + filter: invert(1) brightness(1.15); +} + +/* Models page — provider dropdown rows. + Configured rows look like ordinary picker entries; the .active row's + trailing brand-green ✓ already announces "this is what's selected" + (handled globally by .cfg-dropdown-item.active::after above). + Unconfigured rows are visually subdued and carry a trailing gear icon + as a "click to set up" affordance. */ +.cap-provider-label { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; +} +.cap-provider-gear { + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} +.cap-provider-item.cap-provider-unconfigured { + color: #94a3b8; +} +.dark .cap-provider-item.cap-provider-unconfigured { + color: #64748b; +} +.cap-provider-item.cap-provider-unconfigured:hover { + color: #475569; +} +.dark .cap-provider-item.cap-provider-unconfigured:hover { + color: #cbd5e1; +} +.cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear { + color: #475569; +} +.dark .cap-provider-item.cap-provider-unconfigured:hover .cap-provider-gear { + color: #cbd5e1; +} +/* If the active row ever lands on an unconfigured vendor (defensive — the + click handler normally diverts to the modal), suppress the global ✓ so + the gear remains the sole trailing icon and the row keeps reading as + "needs setup" rather than "already selected". */ +.cap-provider-item.cap-provider-unconfigured.active::after { + content: none; +} + +/* "Add vendor" modal picker — each configured row carries a static + brand-green ✓ via decorateVendorModalPicker so users can see what's set + up at a glance. The active row's global ✓ is suppressed here to avoid + showing two checks side by side on configured + selected rows. */ +.vendor-picker-item.active::after { + content: none; +} +.vendor-picker-configured-mark { + margin-left: auto; + padding-left: 12px; + color: #4abe6e; + font-size: 11px; + flex-shrink: 0; +} +/* "Custom" row is an add-new action: trailing + instead of ✓. */ +.vendor-picker-add-mark { + margin-left: auto; + padding-left: 12px; + color: #94a3b8; + font-size: 11px; + flex-shrink: 0; +} + +/* Chat Input */ +#chat-input { + resize: none; height: 42px; max-height: 180px; + overflow-y: hidden; + transition: border-color 0.2s ease; +} + +/* Attachment Preview Bar */ +.attachment-preview { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 8px 0; +} +.attachment-preview.hidden { display: none; } + +.attach-menu { + position: absolute; + left: 72px; + bottom: calc(100% + 6px); + min-width: 148px; + padding: 6px; + border-radius: 12px; + background: #fff; + border: 1px solid #e2e8f0; + box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04); + z-index: 55; + animation: slashMenuIn 0.15s ease-out; +} +.attach-menu.hidden { display: none; } +.attach-menu-item { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: #334155; + font-size: 13px; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; + text-align: left; +} +.attach-menu-item:hover { + background: #EDFDF3; + color: #228547; +} +.attach-menu-item i { + width: 14px; + text-align: center; + color: #64748b; +} +.attach-menu-item:hover i { color: inherit; } + +.att-thumb { + position: relative; + width: 64px; height: 64px; + border-radius: 8px; + overflow: hidden; + border: 1px solid #e2e8f0; + flex-shrink: 0; +} +.dark .att-thumb { border-color: rgba(255,255,255,0.1); } +.att-thumb img { + width: 100%; height: 100%; + object-fit: cover; +} + +.att-chip { + position: relative; + display: flex; + align-items: center; + gap: 6px; + padding: 6px 28px 6px 10px; + border-radius: 8px; + background: #f1f5f9; + border: 1px solid #e2e8f0; + font-size: 12px; + color: #475569; + max-width: 180px; +} +.dark .att-chip { background: rgba(255,255,255,0.05); border-color: rgba(255,255,255,0.1); color: #94a3b8; } +.att-uploading { opacity: 0.6; pointer-events: none; } +.att-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.att-remove { + position: absolute; + top: -4px; right: -4px; + width: 18px; height: 18px; + border-radius: 50%; + background: #ef4444; + color: #fff; + border: none; + font-size: 12px; + line-height: 18px; + text-align: center; + cursor: pointer; + padding: 0; + opacity: 0; + transition: opacity 0.15s; +} +.att-thumb:hover .att-remove, +.att-chip:hover .att-remove { opacity: 1; } + +/* Drag-over highlight */ +.drag-over { + background: rgba(74, 190, 110, 0.08) !important; + border-color: #4ABE6E !important; +} + +/* User message attachments */ +.user-msg-attachments { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 6px; +} +.user-msg-image { + max-width: 200px; + max-height: 160px; + border-radius: 8px; + object-fit: cover; + cursor: pointer; +} +.user-msg-image:hover { opacity: 0.9; } +.user-msg-file { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 6px; + background: rgba(255,255,255,0.15); + font-size: 12px; +} + +/* Placeholder Cards */ +.placeholder-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} +.placeholder-card:hover { + transform: translateY(-2px); + box-shadow: 0 8px 25px -5px rgba(0, 0, 0, 0.1); +} + +/* Slash Command Menu */ +.slash-menu { + position: absolute; + bottom: calc(100% + 6px); + left: 0; + right: 0; + max-height: 320px; + overflow-y: auto; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.1), 0 2px 8px -2px rgba(0, 0, 0, 0.04); + z-index: 50; + padding: 4px; + animation: slashMenuIn 0.15s ease-out; +} +.slash-menu.hidden { display: none; } + +@keyframes slashMenuIn { + from { opacity: 0; transform: translateY(6px); } + to { opacity: 1; transform: translateY(0); } +} + +.slash-menu-header { + padding: 6px 10px 4px; + font-size: 11px; + font-weight: 600; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.slash-menu-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s ease; +} +.slash-menu-item:hover, +.slash-menu-item.active { + background: #EDFDF3; +} +.slash-menu-item .cmd { + font-size: 13px; + font-weight: 500; + color: #334155; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; +} +.slash-menu-item.active .cmd { + color: #228547; +} +.slash-menu-item .desc { + font-size: 12px; + color: #94a3b8; + margin-left: 12px; + white-space: nowrap; +} + +/* Dark mode */ +.dark .slash-menu { + background: #1A1A1A; + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15); +} +.dark .slash-menu-header { + color: #64748b; +} +.dark .slash-menu-item:hover, +.dark .slash-menu-item.active { + background: rgba(74, 190, 110, 0.1); +} +.dark .slash-menu-item .cmd { + color: #e2e8f0; +} +.dark .slash-menu-item.active .cmd { + color: #4ABE6E; +} +.dark .slash-menu-item .desc { + color: #64748b; +} + +.dark .attach-menu { + background: #1A1A1A; + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 30px -6px rgba(0, 0, 0, 0.35), 0 2px 8px -2px rgba(0, 0, 0, 0.15); +} +.dark .attach-menu-item { + color: #e2e8f0; +} +.dark .attach-menu-item i { + color: #94a3b8; +} +.dark .attach-menu-item:hover { + background: rgba(74, 190, 110, 0.1); + color: #4ABE6E; +} + +/* ============================================================ + Knowledge View + ============================================================ */ + +/* Tab toggle */ +.knowledge-tab, .memory-tab { + color: #64748b; +} +.knowledge-tab.active, .memory-tab.active { + background: #fff; + color: #334155; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} +.dark .knowledge-tab.active, .dark .memory-tab.active { + background: rgba(255,255,255,0.1); + color: #e2e8f0; +} + +/* File tree */ +.knowledge-tree-group { + margin-bottom: 2px; +} +.knowledge-tree-group-btn { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; + font-weight: 600; + color: #64748b; + cursor: pointer; + border: none; + background: none; + transition: background 0.15s, color 0.15s; + text-transform: capitalize; +} +.knowledge-tree-group-btn:hover { + background: rgba(0,0,0,0.04); + color: #334155; +} +.dark .knowledge-tree-group-btn:hover { + background: rgba(255,255,255,0.06); + color: #e2e8f0; +} +.knowledge-tree-group-btn i.chevron { + font-size: 8px; + transition: transform 0.15s; +} +.knowledge-tree-group.open > .knowledge-tree-group-btn .chevron { + transform: rotate(90deg); +} +.knowledge-tree-group-items { + display: none; +} +.knowledge-tree-group.open > .knowledge-tree-group-items { + display: block; +} + +.knowledge-tree-file { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 8px 5px 24px; + border-radius: 6px; + font-size: 12px; + color: #64748b; + cursor: pointer; + border: none; + background: none; + width: 100%; + text-align: left; + transition: background 0.15s, color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.knowledge-tree-file:hover { + background: rgba(0,0,0,0.04); + color: #334155; +} +.knowledge-tree-file.active { + background: #EDFDF3; + color: #228547; +} + +.knowledge-actions { + display: flex; + gap: 2px; + margin-left: auto; + opacity: 0; + transition: opacity 0.15s; +} +.knowledge-tree-file:hover .knowledge-actions, +.knowledge-tree-group-btn:hover .knowledge-actions, +.knowledge-tree-file:focus-within .knowledge-actions, +.knowledge-tree-group-btn:focus-within .knowledge-actions { + opacity: 1; +} +.knowledge-action { + padding: 3px 5px; + border-radius: 5px; + color: #94a3b8; + font-size: 9px; +} +.knowledge-action:hover { + color: #228547; + background: rgba(34, 133, 71, 0.08); +} +.knowledge-action.danger:hover { + color: #ef4444; + background: rgba(239, 68, 68, 0.08); +} +.dark .knowledge-tree-file:hover { + background: rgba(255,255,255,0.06); + color: #e2e8f0; +} +.dark .knowledge-tree-file.active { + background: rgba(74, 190, 110, 0.1); + color: #4ABE6E; +} +.knowledge-import-drag-over { + outline: 2px dashed rgba(74, 190, 110, 0.55); + outline-offset: 4px; + border-radius: 14px; +} +#knowledge-dialog-card.knowledge-document-dialog { + max-width: 760px; +} +#knowledge-document-content { + min-height: 320px; + line-height: 1.55; +} + +/* Graph legend */ +.knowledge-graph-legend { + position: absolute; + top: 12px; + right: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 11px; + color: #64748b; + z-index: 10; +} +.knowledge-graph-legend-item { + display: flex; + align-items: center; + gap: 4px; +} +.knowledge-graph-legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +/* Graph tooltip */ +.knowledge-graph-tooltip { + position: absolute; + padding: 6px 10px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 8px; + font-size: 12px; + color: #334155; + box-shadow: 0 4px 12px rgba(0,0,0,0.08); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s; + z-index: 20; +} +.dark .knowledge-graph-tooltip { + background: #1A1A1A; + border-color: rgba(255,255,255,0.1); + color: #e2e8f0; +} + +/* Config field tooltip */ +.cfg-tip { + position: relative; + display: inline-flex; + align-items: center; + color: #94a3b8; + cursor: help; + font-size: 12px; +} +.cfg-tip:hover { color: #64748b; } +.dark .cfg-tip:hover { color: #cbd5e1; } +/* Floating tooltip portal — appended to by JS so it isn't clipped + by overflow:hidden ancestors. */ +.cfg-tip-floating { + position: fixed; + padding: 6px 10px; + border-radius: 8px; + font-size: 12px; + font-weight: 400; + line-height: 1.4; + white-space: nowrap; + background: #1e293b; + color: #e2e8f0; + box-shadow: 0 4px 12px rgba(0,0,0,0.15); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; + z-index: 9999; +} +.dark .cfg-tip-floating { + background: #334155; + color: #f1f5f9; +} +.cfg-tip-floating.show { + opacity: 1; +} + +/* Example cards: equal height via flex stretch + fixed 2-line description area */ +.example-card { + display: flex; + flex-direction: column; +} +.example-card > p { + flex: 1; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + min-height: 2.5em; /* ~2 lines at text-sm leading-relaxed */ +} + +/* -------------------------------------------------------------------- + * Voice pill — compact custom audio player used by mic uploads and TTS + * replies. Replaces the bulky native