mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-12 23:16:49 +00:00
Compare commits
7 Commits
opencode/s
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0ad6bf20 | ||
|
|
a9c70ab292 | ||
|
|
635eb7a88f | ||
|
|
2ee9f570c4 | ||
|
|
27f4ac6a18 | ||
|
|
44d52cddc5 | ||
|
|
1e38a0e5e0 |
55
.github/workflows/ci.yml
vendored
55
.github/workflows/ci.yml
vendored
@@ -52,58 +52,3 @@ jobs:
|
||||
path: |
|
||||
frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-22.04
|
||||
needs: test
|
||||
|
||||
services:
|
||||
gitea:
|
||||
image: gitea/gitea:1.22
|
||||
ports: ['3333:3000']
|
||||
env:
|
||||
GITEA__database__DB_TYPE: sqlite3
|
||||
GITEA__server__ROOT_URL: http://localhost:3333
|
||||
GITEA__security__INSTALL_LOCK: true
|
||||
GITEA__webhook__ALLOWED_HOST_LIST: '*'
|
||||
GITEA__webhook__SKIP_TLS_VERIFY: true
|
||||
options: >-
|
||||
--health-cmd "curl -f http://localhost:3000/api/v1/version"
|
||||
--health-interval 5s
|
||||
--health-timeout 3s
|
||||
--health-retries 20
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Install git
|
||||
run: sudo apt-get update && sudo apt-get install -y git
|
||||
|
||||
- name: Create Gitea admin user
|
||||
run: |
|
||||
for i in $(seq 1 10); do
|
||||
if docker exec $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null; then
|
||||
echo "User created"
|
||||
break
|
||||
fi
|
||||
echo "Retrying... ($i)"
|
||||
sleep 3
|
||||
done || true
|
||||
docker exec -u git $(docker ps -q --filter "ancestor=gitea/gitea:1.22") \
|
||||
gitea admin user create --username e2e-admin --password 'e2ePassword123!' --email 'e2e@test.local' --admin 2>/dev/null || true
|
||||
|
||||
- name: Run E2E tests
|
||||
run: bun run test:e2e
|
||||
env:
|
||||
E2E_GITEA_URL: http://localhost:3333
|
||||
E2E_MOCK_LLM: 1
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -18,3 +18,5 @@ public/
|
||||
|
||||
# Lock files (frontend has its own)
|
||||
frontend/package-lock.json
|
||||
.omo/
|
||||
.opencode/
|
||||
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -1,3 +1,16 @@
|
||||
# [1.4.0](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.1...v1.4.0) (2026-05-26)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **agent-kernel:** address Oracle review round 2 findings ([27f4ac6](https://github.com/jeffusion/gitea-ai-assistant/commit/27f4ac6a18ec3510575f9234486e2fd5fc72de3c))
|
||||
* **review-agent:** complete fingerprint migration — dual-index all three keys ([2ee9f57](https://github.com/jeffusion/gitea-ai-assistant/commit/2ee9f570c4e43f6fa2901e6a1ff10d79826b7d60))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **agent-kernel:** cherry-pick high-value components from PR [#15](https://github.com/jeffusion/gitea-ai-assistant/issues/15) ([44d52cd](https://github.com/jeffusion/gitea-ai-assistant/commit/44d52cddc5e5301b9ec4ae8ca11ba926dd709cd3)), closes [hi#value](https://github.com/hi/issues/value)
|
||||
|
||||
## [1.3.1](https://github.com/jeffusion/gitea-ai-assistant/compare/v1.3.0...v1.3.1) (2026-03-26)
|
||||
|
||||
|
||||
|
||||
119
CONTRIBUTING.md
Normal file
119
CONTRIBUTING.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Contributing
|
||||
|
||||
## Development setup
|
||||
|
||||
- **Runtime**: [Bun](https://bun.sh) >= 1.2.5
|
||||
- **Backend**: Hono (TypeScript)
|
||||
- **Frontend**: React + Vite + Tailwind CSS
|
||||
- **Database**: SQLite (via Drizzle ORM)
|
||||
|
||||
```bash
|
||||
bun install # install all dependencies
|
||||
bun run dev # start dev server with hot reload
|
||||
```
|
||||
|
||||
## Code quality
|
||||
|
||||
```bash
|
||||
bun run lint # lint backend + frontend
|
||||
bun test # backend unit tests
|
||||
cd frontend && bun test # frontend unit tests
|
||||
bun run build # build backend
|
||||
cd frontend && bun run build # build frontend
|
||||
E2E_MOCK_LLM=1 bun run test:e2e # E2E with mock LLM (no real provider needed)
|
||||
```
|
||||
|
||||
Run all checks before pushing:
|
||||
|
||||
```bash
|
||||
bun run lint && bun run build && bun test && cd frontend && bun run build && bun test
|
||||
```
|
||||
|
||||
## Pull requests
|
||||
|
||||
1. Fork the repository and create a feature branch from `main`
|
||||
2. Make your changes with clear, atomic commits
|
||||
3. Ensure all quality checks pass (lint, build, test)
|
||||
4. Open a PR against `main` with a concise description of the change and motivation
|
||||
|
||||
### Commit style
|
||||
|
||||
Use [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
type(scope): subject
|
||||
|
||||
feat(review): add size-based routing
|
||||
fix(webhook): handle missing signature header
|
||||
chore(deps): bump hono to 4.x
|
||||
docs(config): update env variable table
|
||||
```
|
||||
|
||||
Common types: `feat`, `fix`, `chore`, `docs`, `refactor`, `test`.
|
||||
|
||||
## UI development conventions
|
||||
|
||||
The frontend follows a three-layer design token model:
|
||||
|
||||
1. **Primitive** — HSL base values, defined only in global CSS tokens
|
||||
2. **Semantic** — `background`, `foreground`, `success`, `danger`, etc.
|
||||
3. **Component** — Components consume semantic tokens only. Direct primitive references are forbidden
|
||||
|
||||
### Theme definition
|
||||
|
||||
- Theme file: `frontend/src/index.css`
|
||||
- Tailwind mapping: `frontend/tailwind.config.js`
|
||||
- Primary palette: **Cobalt Blue**, with light/dark variants tuned for contrast
|
||||
|
||||
Available palette presets (`data-palette` attribute): `cobalt` (default), `zinc`, `nord`, `tokyo-night`.
|
||||
|
||||
### Hard rules
|
||||
|
||||
- **No hardcoded dark-theme classes** in business TSX: `bg-zinc-*`, `text-zinc-*`, `border-white/10`, etc.
|
||||
- **No inline color values**: `rgba(...)`, `#xxxxxx`, `rgb(...)`
|
||||
- **Status colors use semantic tokens**: `success` / `warning` / `danger` / `info`
|
||||
- **Panels use semantic surface tokens**: `card`, `muted`, `popover`, `background`
|
||||
- **Interactive glow** uses utility classes: `theme-glow-primary|success|warning|danger`
|
||||
- **Non-primary hover** uses `hover:bg-accent*` or `hover:bg-muted*`. Only primary buttons may use `hover:bg-primary/90`
|
||||
|
||||
### Recommended class patterns
|
||||
|
||||
| Context | Classes |
|
||||
|---|---|
|
||||
| Text | `text-foreground` / `text-muted-foreground` |
|
||||
| Panel | `bg-card` / `bg-muted/50` / `bg-popover` |
|
||||
| Border | `border-border` / `theme-border-soft` |
|
||||
| Status | `text-success` / `bg-danger/10` / `border-warning/30` |
|
||||
| Hover (non-primary) | `hover:bg-accent/60` / `hover:bg-muted/60` |
|
||||
| Hover (primary action) | `hover:bg-primary/90` |
|
||||
| Page frame | `theme-page-frame` / `theme-page-actions` / `theme-page-content` |
|
||||
| Card | `theme-card-shell` / `theme-card-header` / `theme-card-content` |
|
||||
| Dialog | `theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer` |
|
||||
| Error state | `theme-error-panel` |
|
||||
| Sticky bar | `theme-sticky-bar` |
|
||||
| Input surface | `theme-input-surface` |
|
||||
| Control pill | `theme-control-pill` |
|
||||
|
||||
### `destructive` vs `danger`
|
||||
|
||||
- `destructive` — reserved for shadcn built-in destructive variant semantics
|
||||
- `danger` — business status semantics (errors, failures, risk indicators)
|
||||
|
||||
New business components should prefer `danger` to avoid drift.
|
||||
|
||||
### Pre-merge checklist
|
||||
|
||||
- [ ] Page renders correctly in both light and dark themes
|
||||
- [ ] No `zinc`/`white` hardcoded dark-theme classes
|
||||
- [ ] No inline `style` color values
|
||||
- [ ] All status colors use semantic tokens
|
||||
- [ ] Components do not bypass the semantic layer to access primitive colors
|
||||
- [ ] `bun run ui:visual` passes (light/dark visual regression)
|
||||
|
||||
### Visual regression
|
||||
|
||||
- Generate/update baseline: `bun run ui:visual:update`
|
||||
- Verify baseline consistency: `bun run ui:visual`
|
||||
- Full UI check: `bun run ui:regression && bun run ui:visual`
|
||||
|
||||
Baseline snapshots use Linux CI environment (`*-linux.png`). Cross-system snapshot updates introduce noise and should be avoided.
|
||||
@@ -43,7 +43,7 @@ RUN ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \
|
||||
# ---- Stage 4: Production ----
|
||||
FROM oven/bun:1-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates ripgrep curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
104
README.md
104
README.md
@@ -2,104 +2,58 @@
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
AI-powered code review assistant for Gitea. It receives webhooks, runs staged AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
AI-powered code review assistant for Gitea. Receives webhooks, runs AI review workflows, and posts summary + line-level feedback back to Gitea.
|
||||
|
||||
- English docs: [./docs/README.md](./docs/README.md)
|
||||
- 中文文档: [./docs/README.zh-CN.md](./docs/README.zh-CN.md)
|
||||
[English](./docs/README.md) | [中文](./docs/README.zh-CN.md)
|
||||
|
||||
## Why this project
|
||||
## Features
|
||||
|
||||
- 🤖 **Automated PR + commit review** via webhook events (`pull_request`, `status`)
|
||||
- 🧠 **Two review engines**: `agent` (staged tasks) and `codex` (Codex CLI pipeline)
|
||||
- 🧵 **Pluggable LLM providers**: OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- 📍 **Actionable output**: summary comments and line-level findings
|
||||
- 🎛️ **Web Admin UI** for runtime configuration (providers, models, webhook, review policy)
|
||||
- 🔔 **Notifications**: Feishu + WeCom (企业微信)
|
||||
- 🔐 **Security-first defaults**: webhook signature verification + encrypted API key storage
|
||||
- **Automated PR & commit review** via webhook events
|
||||
- **Dynamic Agent engine** — main agent autonomously spawns subagents for focused analysis
|
||||
- **Codex engine** — Codex CLI-backed review as an alternative pipeline
|
||||
- **Pluggable LLM providers** — OpenAI Compatible, OpenAI Responses API, Anthropic, Gemini
|
||||
- **Web Admin UI** — runtime configuration for providers, models, webhook, review policy
|
||||
- **Notifications** — Feishu + WeCom (企业微信)
|
||||
- **Security-first** — webhook signature verification + AES-256-GCM encrypted API key storage
|
||||
|
||||
## Product screenshot
|
||||

|
||||
|
||||
> Dashboard screenshot is generated from local dev service.
|
||||
|
||||

|
||||
|
||||
More screenshots (one per admin menu): [EN](./docs/screenshots.md) | [中文](./docs/screenshots.zh-CN.md)
|
||||
|
||||
## Architecture (high-level)
|
||||
|
||||
```
|
||||
Gitea Webhook -> Gitea AI Assistant (Hono + Bun) -> LLM Gateway (multi-provider)
|
||||
|
|
||||
+-> Admin Dashboard (React)
|
||||
```
|
||||
|
||||
For component-level design, see [Architecture docs](./docs/README.md#architecture--design).
|
||||
|
||||
## Quick start (minimal)
|
||||
|
||||
### 1) Prerequisites
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- Reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
|
||||
### 2) Install
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
git clone https://github.com/jeffusion/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
bun install # installs frontend via postinstall
|
||||
```
|
||||
|
||||
If lifecycle scripts are disabled in your environment, run:
|
||||
Create `.env`:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
```
|
||||
|
||||
### 3) Minimal `.env`
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, 64 hex chars (openssl rand -hex 32)
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # dev default; use LOG_LEVEL=error in production
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is mandatory. The app refuses to start without it.
|
||||
|
||||
### 4) Run
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
```
|
||||
|
||||
### 5) Configure in Admin UI
|
||||
Open `http://localhost:5174`, login with default password `password` (change it immediately), then configure Gitea, LLM providers, and webhook in the Admin UI.
|
||||
|
||||
Open `http://your-server:5174`, login with default `password` (first boot only), then change it immediately.
|
||||
See [Getting Started](./docs/getting-started.md) for full setup walkthrough including webhook configuration.
|
||||
|
||||
- Configure Gitea API + tokens
|
||||
- Configure webhook secret
|
||||
- Configure LLM providers/models
|
||||
- Configure review engine and policy
|
||||
## Documentation
|
||||
|
||||
### 6) Add webhook in Gitea
|
||||
| Topic | Description |
|
||||
|---|---|
|
||||
| [Getting Started](./docs/getting-started.md) | Full installation and setup walkthrough |
|
||||
| [Configuration](./docs/configuration.md) | Environment variables and Admin UI settings |
|
||||
| [Review Engines](./docs/review-engines.md) | Agent engine, Codex engine, review modes |
|
||||
| [Deployment](./docs/deployment.md) | Docker, Compose, and Kubernetes |
|
||||
| [Screenshots](./docs/screenshots.md) | Admin UI gallery |
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content-Type: `application/json`
|
||||
- Secret: same as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
## Contributing
|
||||
|
||||
## Progressive disclosure: detailed docs
|
||||
|
||||
- [Documentation index](./docs/README.md)
|
||||
- [Getting started details](./docs/getting-started.md)
|
||||
- [Configuration reference](./docs/configuration.md)
|
||||
- [Review engines](./docs/review-engines.md)
|
||||
- [Deployment (Docker / Compose / Kubernetes)](./docs/deployment.md)
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development conventions and UI guidelines.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
MIT
|
||||
|
||||
4
bun.lock
4
bun.lock
@@ -8,7 +8,6 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.11.9",
|
||||
@@ -121,9 +120,7 @@
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@qdrant/js-client-rest": ["@qdrant/js-client-rest@1.17.0", "", { "dependencies": { "@qdrant/openapi-typescript-fetch": "1.2.6", "undici": "^6.23.0" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-aZFQeirWVqWAa1a8vJ957LMzcXkFHGbsoRhzc8AkGfg6V0jtK8PlG8/eyyc2xhYsR961FDDx1Tx6nyE0K7lS+A=="],
|
||||
|
||||
"@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="],
|
||||
|
||||
"@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="],
|
||||
|
||||
@@ -735,7 +732,6 @@
|
||||
|
||||
"uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="],
|
||||
|
||||
"undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
version: '3.8'
|
||||
|
||||
# E2E 测试环境:Gitea + gitea-assistant
|
||||
# 用法:
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# ./e2e/seed.sh
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
# docker compose -f docker-compose.e2e.yml up -d
|
||||
# # 等待服务启动后运行 seed 脚本:
|
||||
# ./e2e/seed.sh
|
||||
# # 运行 E2E 测试:
|
||||
# ./e2e/test.sh
|
||||
# # 清理:
|
||||
# docker compose -f docker-compose.e2e.yml down -v
|
||||
|
||||
services:
|
||||
gitea:
|
||||
@@ -40,16 +46,12 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=5752fac0e57d00e9b7954863faef878693420e6b06bc20d710897587e802668a
|
||||
- REVIEW_ENGINE=kernel
|
||||
- REVIEW_WORKDIR=/tmp/e2e-review
|
||||
- DATABASE_PATH=/data/assistant.db
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
ports:
|
||||
- "3334:5174"
|
||||
volumes:
|
||||
- assistant-data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5174/api/health"]
|
||||
interval: 5s
|
||||
@@ -59,4 +61,3 @@ services:
|
||||
|
||||
volumes:
|
||||
gitea-data:
|
||||
assistant-data:
|
||||
|
||||
@@ -15,9 +15,6 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
LOG_LEVEL: error
|
||||
depends_on:
|
||||
qdrant:
|
||||
condition: service_healthy
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
@@ -38,30 +35,6 @@ services:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
qdrant:
|
||||
image: qdrant/qdrant:latest
|
||||
container_name: qdrant
|
||||
ports:
|
||||
- "6333:6333"
|
||||
- "6334:6334"
|
||||
volumes:
|
||||
- qdrant_data:/qdrant/storage
|
||||
restart: unless-stopped
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:6333/healthz"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 1G
|
||||
|
||||
volumes:
|
||||
qdrant_data:
|
||||
driver: local
|
||||
assistant_data:
|
||||
driver: local
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
# Documentation
|
||||
|
||||
This project keeps the root `README.md` concise and moves implementation/deployment details here.
|
||||
Setup, configuration, and deployment guides for Gitea AI Assistant.
|
||||
|
||||
## Start here
|
||||
## Getting started
|
||||
|
||||
- [Getting started](./getting-started.md)
|
||||
- [Configuration reference](./configuration.md)
|
||||
- [Review engines](./review-engines.md)
|
||||
- [Deployment](./deployment.md)
|
||||
- [Screenshot gallery](./screenshots.md)
|
||||
- [Getting Started](./getting-started.md) — full installation, first login, and webhook setup walkthrough
|
||||
- [Screenshots](./screenshots.md) — Admin UI gallery (one page per feature area)
|
||||
|
||||
## Architecture & design
|
||||
## Reference
|
||||
|
||||
- [Pluggable LLM providers](./design/pluggable-llm-providers.md)
|
||||
- [Kernel built-in Agent architecture](./design/kernel-built-in-agents.md)
|
||||
- [Notification service refactoring](./design/notification-service-refactoring.md)
|
||||
- [UI theme language](./design/ui-theme-language.md)
|
||||
- [Configuration](./configuration.md) — environment variables, Admin UI settings, and runtime configuration model
|
||||
- [Review Engines](./review-engines.md) — Agent engine, Codex engine, review modes, size policy, and agent definitions
|
||||
|
||||
## Deployment
|
||||
|
||||
- [Deployment](./deployment.md) — Docker, Docker Compose, and Kubernetes
|
||||
|
||||
## Language
|
||||
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
# 文档中心
|
||||
|
||||
根目录 `README.md` 保持简洁;本目录提供详细说明与设计文档。
|
||||
Gitea AI Assistant 的安装、配置与部署指南。
|
||||
|
||||
## 快速导航
|
||||
## 快速开始
|
||||
|
||||
- [快速开始](./getting-started.zh-CN.md)
|
||||
- [配置参考](./configuration.zh-CN.md)
|
||||
- [审查引擎](./review-engines.zh-CN.md)
|
||||
- [部署指南](./deployment.zh-CN.md)
|
||||
- [截图集](./screenshots.zh-CN.md)
|
||||
- [快速开始](./getting-started.zh-CN.md) — 完整安装、首次登录与 Webhook 配置指引
|
||||
- [截图集](./screenshots.zh-CN.md) — 管理后台界面一览(每个功能页面一张截图)
|
||||
|
||||
## 架构与设计
|
||||
## 参考手册
|
||||
|
||||
- [可插拔 LLM 提供商设计](./design/pluggable-llm-providers.md)
|
||||
- [Kernel 内置 Agent 架构设计](./design/kernel-built-in-agents.md)
|
||||
- [通知服务重构设计](./design/notification-service-refactoring.md)
|
||||
- [UI 主题语言设计](./design/ui-theme-language.md)
|
||||
- [配置参考](./configuration.zh-CN.md) — 环境变量、管理后台设置与运行时配置模型
|
||||
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎、审查模式、规模策略与 Agent 定义
|
||||
|
||||
## 产品截图
|
||||
## 部署
|
||||
|
||||

|
||||
- [部署指南](./deployment.zh-CN.md) — Docker、Docker Compose 与 Kubernetes
|
||||
|
||||
## 语言切换
|
||||
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
|
||||
## Configuration model
|
||||
|
||||
This project uses a DB-first runtime configuration model:
|
||||
This project uses a **DB-first** runtime configuration model:
|
||||
|
||||
- `.env` contains only infrastructure-level bootstrap values.
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and stored in SQLite.
|
||||
- `.env` stores only infrastructure-level bootstrap values
|
||||
- Runtime settings (Gitea, providers, secrets, review policy, notifications) are managed in Admin UI and persisted to SQLite
|
||||
|
||||
## Environment variables (minimal)
|
||||
This means you configure most settings through the web dashboard after first boot, not through environment variables.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Required | Description | Default |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key (64 hex chars) for API key encryption | - |
|
||||
| `ENCRYPTION_KEY` | Yes | AES-256-GCM master key for API key encryption (64 hex chars) | — |
|
||||
| `PORT` | No | Service port | `5174` |
|
||||
| `DATABASE_PATH` | No | SQLite path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level (`debug`/`info`/`warn`/`error`). Default is `info`; use `error` in production. | `info` |
|
||||
| `DATABASE_PATH` | No | SQLite database path | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | No | Backend log level: `debug` / `info` / `warn` / `error` | `info` |
|
||||
|
||||
Generate key:
|
||||
Generate encryption key:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
@@ -24,55 +26,57 @@ openssl rand -hex 32
|
||||
|
||||
## First boot defaults
|
||||
|
||||
When database is empty:
|
||||
When the database is empty on first launch:
|
||||
|
||||
- `JWT_SECRET` auto-generated
|
||||
- `WEBHOOK_SECRET` auto-generated
|
||||
- `ADMIN_PASSWORD` defaults to `password`
|
||||
- `JWT_SECRET` — auto-generated
|
||||
- `WEBHOOK_SECRET` — auto-generated
|
||||
- `ADMIN_PASSWORD` — defaults to `password` (**change immediately after login**)
|
||||
|
||||
Change `ADMIN_PASSWORD` immediately after first login.
|
||||
## Admin UI settings
|
||||
|
||||
## Runtime groups in Admin UI
|
||||
All settings below are configured through the Admin UI at `http://your-server:5174`.
|
||||
|
||||
## 1) Gitea
|
||||
### Gitea
|
||||
|
||||
- API URL
|
||||
- Access token
|
||||
- Admin token (optional)
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| API URL | Gitea API endpoint (e.g. `http://gitea:3000/api/v1`) |
|
||||
| Access Token | Token for cloning repos and posting comments |
|
||||
| Admin Token | Optional; required for repository discovery |
|
||||
|
||||
## 2) Security
|
||||
### Security
|
||||
|
||||
- Webhook secret (HMAC-SHA256 verification)
|
||||
- Admin password
|
||||
- JWT secret
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Webhook Secret | HMAC-SHA256 key for verifying incoming webhooks |
|
||||
| Admin Password | Dashboard login password |
|
||||
| JWT Secret | Token signing key (auto-generated on first boot) |
|
||||
|
||||
## 3) LLM
|
||||
### LLM
|
||||
|
||||
- Providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- Role mapping: planner, specialist, judge, embedding
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Providers | Add one or more providers: OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
|
||||
| `AGENT_MAIN_MODEL` | Default model for the main agent runtime. Default: `gpt-4.1` |
|
||||
| `AGENT_DEFAULT_SUBAGENT_MODEL` | Default model for subagents when not declared in definition or spawn. Default: `gpt-4.1-mini` |
|
||||
|
||||
## 4) Notification
|
||||
Model resolution order: `spawn override > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
|
||||
|
||||
- Feishu webhook and optional secret
|
||||
- WeCom (企业微信) webhook
|
||||
### Notifications
|
||||
|
||||
## 5) Review
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Feishu Webhook | Feishu bot webhook URL and optional signing secret |
|
||||
| WeCom Webhook | WeCom (企业微信) bot webhook URL |
|
||||
|
||||
- Engine mode: `agent` or `codex`
|
||||
- Triage switch
|
||||
- Size thresholds (`small`/`medium`/`large`)
|
||||
- Execution modes (`skip`/`light`/`full`)
|
||||
- Token budgets and concurrency limits
|
||||
### Review
|
||||
|
||||
> Size and mode are different layers:
|
||||
>
|
||||
> - `small/medium/large`: change-size classification
|
||||
> - `skip/light/full`: review execution depth
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| Engine | `agent` or `codex` |
|
||||
| Size thresholds | `small` / `medium` / `large` — classifies change size |
|
||||
| Execution modes | `skip` / `light` / `full` — controls review depth |
|
||||
| Token budgets | Per-mode token limits |
|
||||
| Concurrency | Max parallel review runs |
|
||||
|
||||
## 6) Memory & learning (optional)
|
||||
|
||||
- `ENABLE_MEMORY` (default `false`)
|
||||
- Qdrant URL
|
||||
- Reflection/debate toggles
|
||||
|
||||
Qdrant is only required when memory is enabled.
|
||||
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
|
||||
|
||||
@@ -2,21 +2,23 @@
|
||||
|
||||
## 配置模型
|
||||
|
||||
项目采用 DB-first 运行时配置模型:
|
||||
项目采用 **DB-first** 运行时配置模型:
|
||||
|
||||
- `.env` 仅用于基础设施级引导参数
|
||||
- `.env` 仅存储基础设施级引导参数
|
||||
- 运行时配置(Gitea、Provider、密钥、审查策略、通知)由管理后台维护并持久化到 SQLite
|
||||
|
||||
## 环境变量(最小集)
|
||||
即大部分设置在首次启动后通过 Web 管理后台配置,而非环境变量。
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量 | 必填 | 说明 | 默认值 |
|
||||
|---|---|---|---|
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | - |
|
||||
| `ENCRYPTION_KEY` | 是 | API Key 加密主密钥(AES-256-GCM,64 位十六进制) | — |
|
||||
| `PORT` | 否 | 服务端口 | `5174` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别(`debug`/`info`/`warn`/`error`)。默认 `info`;生产环境建议 `error`。 | `info` |
|
||||
| `DATABASE_PATH` | 否 | SQLite 数据库路径 | `./data/assistant.db` |
|
||||
| `LOG_LEVEL` | 否 | 后端日志级别:`debug` / `info` / `warn` / `error` | `info` |
|
||||
|
||||
生成密钥:
|
||||
生成加密密钥:
|
||||
|
||||
```bash
|
||||
openssl rand -hex 32
|
||||
@@ -24,55 +26,57 @@ openssl rand -hex 32
|
||||
|
||||
## 首次启动默认值
|
||||
|
||||
当数据库为空时:
|
||||
数据库为空时首次启动:
|
||||
|
||||
- `JWT_SECRET` 自动生成
|
||||
- `WEBHOOK_SECRET` 自动生成
|
||||
- `ADMIN_PASSWORD` 默认 `password`
|
||||
- `JWT_SECRET` — 自动生成
|
||||
- `WEBHOOK_SECRET` — 自动生成
|
||||
- `ADMIN_PASSWORD` — 默认 `password`(**登录后请立即修改**)
|
||||
|
||||
首次登录后请立即修改管理员密码。
|
||||
## 管理后台设置
|
||||
|
||||
## 管理后台配置分组
|
||||
以下所有设置均通过管理后台 `http://your-server:5174` 配置。
|
||||
|
||||
## 1) Gitea
|
||||
### Gitea
|
||||
|
||||
- API URL
|
||||
- Access Token
|
||||
- Admin Token(可选)
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| API URL | Gitea API 端点(如 `http://gitea:3000/api/v1`) |
|
||||
| Access Token | 用于克隆仓库和发布评论的令牌 |
|
||||
| Admin Token | 可选;仓库发现功能需要 |
|
||||
|
||||
## 2) 安全
|
||||
### 安全
|
||||
|
||||
- Webhook Secret(HMAC-SHA256 验签)
|
||||
- Admin Password
|
||||
- JWT Secret
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Webhook Secret | HMAC-SHA256 签名验证密钥 |
|
||||
| Admin Password | 管理后台登录密码 |
|
||||
| JWT Secret | Token 签名密钥(首次启动自动生成) |
|
||||
|
||||
## 3) LLM
|
||||
### LLM
|
||||
|
||||
- Provider:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini
|
||||
- 角色模型:planner、specialist、judge、embedding
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Provider | 添加一个或多个提供商:OpenAI Compatible / OpenAI Responses / Anthropic / Gemini |
|
||||
| `AGENT_MAIN_MODEL` | 主 Agent 运行时默认模型。默认值:`gpt-4.1` |
|
||||
| `AGENT_DEFAULT_SUBAGENT_MODEL` | 子 Agent 未声明模型且 spawn 未覆盖时的默认模型。默认值:`gpt-4.1-mini` |
|
||||
|
||||
## 4) 通知
|
||||
模型解析顺序:`spawn 覆盖 > AgentDefinition.model > AGENT_DEFAULT_SUBAGENT_MODEL > AGENT_MAIN_MODEL`
|
||||
|
||||
- Feishu Webhook 与可选签名密钥
|
||||
- WeCom(企业微信)Webhook
|
||||
### 通知
|
||||
|
||||
## 5) 审查
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| Feishu Webhook | 飞书机器人 Webhook URL 及可选签名密钥 |
|
||||
| WeCom Webhook | 企业微信机器人 Webhook URL |
|
||||
|
||||
- 引擎模式:`agent` / `codex`
|
||||
- Triage 开关
|
||||
- 规模阈值(`small`/`medium`/`large`)
|
||||
- 执行模式(`skip`/`light`/`full`)
|
||||
- Token 预算与并发限制
|
||||
### 审查
|
||||
|
||||
> 规模与模式是两个层次:
|
||||
>
|
||||
> - `small/medium/large`:变更规模分类
|
||||
> - `skip/light/full`:审查执行深度
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| 引擎 | `agent` 或 `codex` |
|
||||
| 规模阈值 | `small` / `medium` / `large` — 变更规模分类 |
|
||||
| 执行模式 | `skip` / `light` / `full` — 审查深度控制 |
|
||||
| Token 预算 | 各模式 Token 限额 |
|
||||
| 并发限制 | 最大并行审查数 |
|
||||
|
||||
## 6) 记忆与学习(可选)
|
||||
|
||||
- `ENABLE_MEMORY`(默认 `false`)
|
||||
- Qdrant URL
|
||||
- Reflection / Debate 开关
|
||||
|
||||
仅在开启记忆能力时需要 Qdrant。
|
||||
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
|
||||
|
||||
@@ -13,15 +13,10 @@ docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error g
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` includes both:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
`docker-compose.yml` includes `gitea-assistant`.
|
||||
|
||||
Production default in compose sets `LOG_LEVEL=error`.
|
||||
|
||||
If you do not use memory features, Qdrant can be optional in custom compose setups.
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes manifests are in `k8s/`.
|
||||
@@ -46,7 +41,6 @@ Or apply individually:
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
|
||||
@@ -13,15 +13,10 @@ docker run -d -p 5174:5174 -v ./data:/app/data -e PORT=5174 -e LOG_LEVEL=error g
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
`docker-compose.yml` 默认包含:
|
||||
|
||||
- `gitea-assistant`
|
||||
- `qdrant`
|
||||
`docker-compose.yml` 默认包含 `gitea-assistant`。
|
||||
|
||||
Compose 生产默认日志级别已设置为 `LOG_LEVEL=error`。
|
||||
|
||||
如果不使用记忆能力,可在自定义编排中将 Qdrant 设为可选。
|
||||
|
||||
## Kubernetes
|
||||
|
||||
Kubernetes 清单位于 `k8s/` 目录。
|
||||
@@ -46,7 +41,6 @@ kubectl apply -k k8s/
|
||||
|
||||
```bash
|
||||
kubectl apply -f k8s/namespace.yaml
|
||||
kubectl apply -f k8s/qdrant.yaml
|
||||
kubectl apply -f k8s/gitea-assistant.yaml
|
||||
```
|
||||
|
||||
|
||||
@@ -1,890 +0,0 @@
|
||||
# 技术设计文档:Kernel 内置 Agent 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-04-28
|
||||
> **相关模块**: `src/agent-kernel/`、`src/review/kernel/`
|
||||
> **适用范围**: Review Kernel 的内置 subagent 体系、运行时委派、管理后台可观测能力与生产测试门禁
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 文档信息](#0-文档信息)
|
||||
- [1. 背景与目标](#1-背景与目标)
|
||||
- [2. 设计原则与关键取舍](#2-设计原则与关键取舍)
|
||||
- [3. 概要设计](#3-概要设计)
|
||||
- [4. 内置 Agent 详细设计](#4-内置-agent-详细设计)
|
||||
- [4.8 Agent工作机制详解](#48-agent工作机制详解)
|
||||
- [5. 运行时与状态设计](#5-运行时与状态设计)
|
||||
- [6. API 与管理后台可观测性](#6-api-与管理后台可观测性)
|
||||
- [7. 非功能性设计](#7-非功能性设计)
|
||||
- [8. 测试与上线验证](#8-测试与上线验证)
|
||||
- [9. 风险、待确认与后续演进](#9-风险待确认与后续演进)
|
||||
|
||||
---
|
||||
|
||||
## 0. 文档信息
|
||||
|
||||
| 字段 | 内容 |
|
||||
|---|---|
|
||||
| 版本 | v0.1 |
|
||||
| 状态 | 草案 |
|
||||
| 目标读者 | 研发 / 架构 / QA / 运维 / 管理后台开发 |
|
||||
| 系统类型 | AI 应用工程 / 后端 Agent Runtime / 审查系统适配层 |
|
||||
| 主要代码路径 | `src/agent-kernel/`、`src/review/kernel/` |
|
||||
| 相关配置 | `REVIEW_ENGINE=kernel` |
|
||||
|
||||
### Assumptions
|
||||
|
||||
- 当前项目已选择 **kernel-first** 作为代码审查主路径;旧固定 agent 编排不作为未来运行时主路径。
|
||||
- 内置 Agent 当前以 **built-in subagent definition** 的方式注册,后续可演进到 plugin/custom subagent 加载。
|
||||
- 一条 PR 对应一个 kernel session,commit 更新、人工反馈和后续恢复都写入同一 session。
|
||||
|
||||
### To Be Confirmed
|
||||
|
||||
- 是否需要把 built-in subagent 的定义从 TypeScript 代码进一步外置为 YAML/JSON/插件目录。
|
||||
- 管理后台是否需要支持逐 subagent 的启用/禁用、版本选择与灰度策略。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 背景
|
||||
|
||||
早期审查系统采用固定流程编排:triage 后按审查域派生多个 specialist,再由额外阶段汇总。该方案的问题是:
|
||||
|
||||
- 流程扩展需要修改 orchestrator/runtime 代码;
|
||||
- 角色能力与执行链路耦合,难以按能力标签选择代理;
|
||||
- 缺少独立 subagent identity、delegation boundary 和 invocation trace;
|
||||
- 管理后台难以展示“有哪些 Agent、何时被调用、产生了什么结果”;
|
||||
- 恢复、压缩、权限、hook 等横切能力难以统一接入。
|
||||
|
||||
新的 Kernel 内置 Agent 架构将 review 角色转换为注册式 built-in subagents,由 `AgentKernelRunner` 根据 planner 输出与 session state 推进任务,并通过 `KernelAgentInvoker` 统一委派执行。
|
||||
|
||||
### 1.2 核心目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|---|---|
|
||||
| 注册式扩展 | 内置 Agent 以 `KernelSubagentDefinition` 注册,runtime 不硬编码角色实例 |
|
||||
| 能力选择 | planner 通过 tags/capabilities 选择 subagent,而不是写死 agent id |
|
||||
| 可恢复执行 | session checkpoint 持久化 state + pendingTasks,支持 feedback 后继续执行 |
|
||||
| 委派边界 | 每次 subagent 调用都有 agentId、delegation packet、invocation record、structured result |
|
||||
| 上下文压缩 | 大上下文触发 compression,summary 写入 checkpoint 并回注后续 subagent |
|
||||
| 工具治理 | 工具调用走统一 orchestration、permission gating 与 hooks |
|
||||
| 可观测性 | 管理 API 暴露 task/subagent/hook catalog、session timeline、subagent invocations |
|
||||
|
||||
### 1.3 范围与非范围
|
||||
|
||||
**范围内**:
|
||||
|
||||
- Review Kernel 内置 subagents 的定义、职责、标签、运行链路;
|
||||
- Kernel agent registry / invoker / runner 与 session checkpoint 的协作;
|
||||
- 内置 Agent 与 tools、hooks、permission、compression 的集成方式;
|
||||
- 管理后台需要消费的 catalog 与 session 投影视图;
|
||||
- 生产前自动化测试门禁。
|
||||
|
||||
**范围外**:
|
||||
|
||||
- 前端 UI 视觉设计细节;
|
||||
- 旧 `agent` 固定编排引擎兼容;
|
||||
- Codex CLI 引擎内部实现;
|
||||
- 通用插件市场、远程 agent 执行后端和多租户权限模型。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计原则与关键取舍
|
||||
|
||||
### 2.1 核心设计原则
|
||||
|
||||
| 原则 | 落地方式 |
|
||||
|---|---|
|
||||
| 高内聚低耦合 | `src/agent-kernel/` 只提供通用 session/runner/registry/invoker/hooks;review 逻辑放在 `src/review/kernel/` |
|
||||
| 开闭原则 | 新增流程能力优先增加 subagent、skill、hook 或 tool,而不是修改主循环 |
|
||||
| Session 为状态源 | PR/commit session 记录 event、checkpoint、subagent invocation,是恢复与投影的事实来源 |
|
||||
| 可观测优先 | 每次 subagent 调用持久化 invocation;每个 task 写入 started/completed/failed event |
|
||||
| 安全默认 | 工具执行统一经过 permission gating;高风险 scope 默认 ask/deny |
|
||||
| 可测试 | 断言面落在 checkpoint、events、invocations、tool result、admin projection,而不是完整 LLM 文本 |
|
||||
|
||||
### 2.2 关键取舍
|
||||
|
||||
| 取舍点 | 选择 | 原因 |
|
||||
|---|---|---|
|
||||
| 内置 Agent 表达方式 | TypeScript built-in definitions | 当前阶段需要强类型、低迁移成本;后续可迁移到 plugin loader |
|
||||
| Agent 调用入口 | `KernelAgentInvoker` 统一调用 | 统一 agentId、hook、invocation persistence、structured result |
|
||||
| 流程推进方式 | planner + session state | 避免静态任务数组;支持继续执行与人审恢复 |
|
||||
| Findings 处理 | 本地归一化、去重、排序与发布 | full review 只产出 findings;后续由 skill/本地逻辑保证确定性 |
|
||||
| 压缩策略 | planner 模型窗口 80% 触发 | 使用 tokenlens context window,预留 20% 冗余 |
|
||||
| 管理接口 | task/subagent/hook catalog + session detail | 让后台可解释当前能力目录与执行轨迹 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 概要设计
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[(Kernel Session Repository)]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runtime --> SkillRegistry[KernelTaskRegistry / Skills]
|
||||
Runtime --> AgentRegistry[KernelAgentRegistry / Built-in Subagents]
|
||||
Runtime --> HookRegistry[KernelHookRegistry]
|
||||
Runtime --> ToolRegistry[ToolRegistry]
|
||||
|
||||
Runner --> Planner[State-driven Planner]
|
||||
Planner --> SkillTask[Skill Task]
|
||||
Planner --> SubagentTask[Subagent Task]
|
||||
|
||||
SkillTask --> SkillRegistry
|
||||
SubagentTask --> Invoker[KernelAgentInvoker]
|
||||
Invoker --> AgentContext[AsyncLocalStorage Agent Context]
|
||||
Invoker --> Invocation[(Subagent Invocation Record)]
|
||||
Invoker --> Builtins[Review Built-in Subagents]
|
||||
|
||||
Builtins --> Triage[review:triage]
|
||||
Builtins --> FullReview[review:full_review]
|
||||
|
||||
FullReview --> ToolOrchestration[Tool Orchestration]
|
||||
ToolOrchestration --> Permission[Permission Gating]
|
||||
ToolOrchestration --> Hooks[Pre/Post Tool Hooks]
|
||||
|
||||
Runtime --> AdminAPI[Admin API Catalog / Session Projection]
|
||||
```
|
||||
|
||||
### 3.2 模块职责
|
||||
|
||||
| 模块 | 文件 | 职责 |
|
||||
|---|---|---|
|
||||
| Kernel types | `src/agent-kernel/types.ts` | 定义 task、subagent、delegation packet、checkpoint、invocation result |
|
||||
| Agent registry | `src/agent-kernel/agents/kernel-agent-registry.ts` | 注册、查询、按 tag 过滤 subagent |
|
||||
| Agent invoker | `src/agent-kernel/agents/kernel-agent-invoker.ts` | 创建 agentId、触发 hook、持久化 invocation、执行 subagent |
|
||||
| Agent context | `src/agent-kernel/agents/kernel-agent-context.ts` | 使用 AsyncLocalStorage 隔离子代理执行上下文 |
|
||||
| Runner | `src/agent-kernel/runtime/agent-kernel-runner.ts` | 按 planner 结果推进 skill/subagent task,写 checkpoint 与 task event |
|
||||
| Session repo | `src/agent-kernel/session/session-repository.ts` | 持久化 session、events、checkpoint、subagent invocations |
|
||||
| Review runtime | `src/review/kernel/review-kernel-runtime.ts` | 注册 skills/hooks/built-in subagents,提供 execute/continueExecution |
|
||||
| Built-in subagents | `src/review/kernel/review-built-in-subagents.ts` | 将 triage 与 full_review 转换为注册式 subagent definitions |
|
||||
| Subagent ids | `src/review/kernel/review-subagent-ids.ts` | 统一内置 subagent id 命名 |
|
||||
| Admin projection | `src/review/kernel/session-read-model.ts` | 将 session event/checkpoint/invocation 投影为后台视图 |
|
||||
|
||||
### 3.3 核心执行链路
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant E as KernelReviewEngine
|
||||
participant R as ReviewKernelRuntime
|
||||
participant S as SessionRepository
|
||||
participant K as AgentKernelRunner
|
||||
participant I as KernelAgentInvoker
|
||||
participant A as Built-in Subagent
|
||||
|
||||
E->>S: ensureSession(scopeKey)
|
||||
E->>R: execute(run, sessionId)
|
||||
R->>S: appendEvent(run_started)
|
||||
R->>K: run(initialState, initialTasks=[])
|
||||
loop until stopReason
|
||||
K->>K: planner.plan(state)
|
||||
alt skill task
|
||||
K->>R: execute skill handler
|
||||
else subagent task
|
||||
K->>I: invoke(task, context)
|
||||
I->>S: createSubagentInvocation(running)
|
||||
I->>A: execute(task, agentContext)
|
||||
A-->>I: KernelHandlerResult
|
||||
I->>S: completeSubagentInvocation(completed)
|
||||
end
|
||||
K->>S: appendEvent(task_completed)
|
||||
K->>S: saveCheckpoint(state, pendingTasks, stopReason)
|
||||
end
|
||||
R->>S: appendEvent(run_completed)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 内置 Agent 详细设计
|
||||
|
||||
### 4.1 内置 Agent 目录
|
||||
|
||||
| Subagent ID | Source | Model Role | Tags | 职责 | 触发条件 |
|
||||
|---|---|---|---|---|---|
|
||||
| `review:triage` | `built-in` | `planner` | `review`, `planner`, `triage` | 根据 diff、文件、风险生成自主审查提示、模式和预算 | build context 完成且尚无 triage 结果 |
|
||||
| `review:full_review` | `built-in` | `specialist` | `review`, `specialist`, `full-review`, `autonomous-review` | 执行一次完整自主代码审查,模型自行选择工具和调查路径 | triage 完成且尚未完成 full review |
|
||||
|
||||
### 4.2 Subagent Definition 契约
|
||||
|
||||
每个内置 Agent 必须实现 `KernelSubagentDefinition<TState>`:
|
||||
|
||||
```typescript
|
||||
interface KernelSubagentDefinition<TState> {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: 'built-in' | 'custom' | 'plugin';
|
||||
description: string;
|
||||
whenToUse: string;
|
||||
tags?: string[];
|
||||
modelRole?: string;
|
||||
resumable?: boolean;
|
||||
execute(task, context): Promise<KernelHandlerResult<TState> | undefined>;
|
||||
}
|
||||
```
|
||||
|
||||
关键约束:
|
||||
|
||||
- `name` 必须稳定,作为 session event、invocation、admin catalog 的统一标识;
|
||||
- `tags` 必须包含能力标签,planner 只能按 tag/capability 选择代理;
|
||||
- `whenToUse` 既用于管理后台解释,也用于 delegation packet 的 goal;
|
||||
- `execute` 不直接控制主循环,只返回 state/enqueue/prepend/stopReason;
|
||||
- 内置 Agent 不应越权直接修改 pendingTasks,除非通过标准 `KernelHandlerResult`。
|
||||
|
||||
### 4.3 Planner 选择规则
|
||||
|
||||
`ReviewKernelRuntime.planTasks()` 根据 checkpoint state 推导下一步:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[开始 plan] --> B{有 pendingTasks?}
|
||||
B -- 是 --> Z[不新增任务]
|
||||
B -- 否 --> C{缺 workspace?}
|
||||
C -- 是 --> PW[prepare_workspace skill]
|
||||
C -- 否 --> D{缺 context?}
|
||||
D -- 是 --> BC[build_context skill]
|
||||
D -- 否 --> E{需要压缩?}
|
||||
E -- 是 --> CC[compress_context skill]
|
||||
E -- 否 --> F{缺 triage?}
|
||||
F -- 是 --> T[按 tag=triage 选择 review:triage]
|
||||
F -- 否 --> G{full review 未完成?}
|
||||
G -- 是 --> S[执行 review:full_review]
|
||||
G -- 否 --> P{未 publish?}
|
||||
P -- 是 --> PR[publish_review skill]
|
||||
P -- 否 --> R{未保存 reviewed ref?}
|
||||
R -- 是 --> SR[save_reviewed_ref skill]
|
||||
R -- 否 --> DONE[completed]
|
||||
```
|
||||
|
||||
### 4.4 Triage Agent
|
||||
|
||||
`review:triage` 包装 `TriageAgent`,输出自主审查提示:
|
||||
|
||||
- 使用 `planner` 模型角色;
|
||||
- 接收 `projectPrompt` 和 `compressedContext.summary`;
|
||||
- 生成 `mode`、`reviewSize`、`riskTags`、`suspectedEntrypoints` 与预算提示;
|
||||
- 提示只影响 full review 的调查起点,不拆分审查任务。
|
||||
|
||||
### 4.5 Autonomous Full Review Agent
|
||||
|
||||
`review:full_review` 包装 `AutonomousReviewAgent`:
|
||||
|
||||
- 共享 `ToolRegistry` 与 `KernelHookRegistry`;
|
||||
- 根据 `ReviewTask` 控制 mode、reviewSize、riskTags、suspectedEntrypoints、maxTurns、maxToolCalls、maxElapsedMs、tokenBudget;
|
||||
- 支持压缩 summary 回注到 prompt;
|
||||
- 不预拆 correctness/security/quality 子任务,模型在一次自主循环内跨文件调查;
|
||||
- 工具调用统一经过 tool orchestration、permission gating、Pre/Post tool hooks。
|
||||
|
||||
### 4.6 Aggregate Findings Skill
|
||||
|
||||
`aggregate_findings` 是 full review 后的确定性本地步骤:
|
||||
|
||||
- 接收 `review:full_review` 产出的 findings;
|
||||
- 归一化 category/severity/confidence,补齐 fingerprint;
|
||||
- 按 fingerprint 去重,并按 severity/path/line/title 稳定排序;
|
||||
- 写回 checkpoint,供后续发布步骤使用。
|
||||
|
||||
### 4.7 Publish and Save Skills
|
||||
|
||||
`publish_review` 与 `save_reviewed_ref` 负责外部副作用:
|
||||
|
||||
- `publish_review` 生成确定性 summary,并发布 PR summary 与 line comments;
|
||||
- `save_reviewed_ref` 在本地 mirror 保存已审查 ref,用于后续增量审查;
|
||||
- 两个步骤分离,避免评论发布和 ref 保存互相污染,失败时依赖 checkpoint 重试。
|
||||
|
||||
---
|
||||
|
||||
## 4.8 Agent工作机制详解
|
||||
|
||||
本节详细说明 Kernel Agent 的运转机制、任务调度、工具调用、决策逻辑及边界划分。
|
||||
|
||||
### 4.8.1 核心运转架构
|
||||
|
||||
Kernel 采用「**事件驱动 + 状态机**」的运行模式:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Webhook[Gitea Webhook / Feedback] --> Engine[KernelReviewEngine]
|
||||
Engine --> Session[Session Repository]
|
||||
Engine --> Runtime[ReviewKernelRuntime]
|
||||
Runtime --> Runner[AgentKernelRunner]
|
||||
Runner --> Planner[Turn Planner]
|
||||
Planner --> Tasks[Tasks Queue]
|
||||
Tasks --> Executor[Task Executor]
|
||||
Executor --> State[State Update]
|
||||
State --> Checkpoint[Checkpoint Save]
|
||||
Checkpoint --> Runner
|
||||
```
|
||||
|
||||
**关键组件职责**:
|
||||
|
||||
| 组件 | 文件 | 核心职责 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | `agent-kernel-runner.ts` | 主循环控制器:任务调度、状态流转、checkpoint 管理 |
|
||||
| **ReviewKernelRuntime** | `review-kernel-runtime.ts` | Review 业务运行时:封装 skills、subagents、hooks、tools |
|
||||
| **KernelTurnPlanner** | `review-kernel-runtime.ts:305-361` | 基于当前 state 决定下一步执行什么任务 |
|
||||
|
||||
### 4.8.2 核心运转流程
|
||||
|
||||
**1. 启动阶段**:
|
||||
```typescript
|
||||
// PR webhook 触发
|
||||
kernelReviewEngine.enqueuePullRequest(payload)
|
||||
→ ensureSession(scopeKey) // 创建或复用 session
|
||||
→ runtime.execute(run, sessionId) // 启动运行时
|
||||
→ AgentKernelRunner.run({ // 启动主循环
|
||||
sessionId,
|
||||
initialState: {...},
|
||||
initialTasks: []
|
||||
})
|
||||
```
|
||||
|
||||
**2. 主循环机制** (`AgentKernelRunner.run`):
|
||||
|
||||
```typescript
|
||||
async run({ sessionId, initialState, initialTasks, continueExisting }) {
|
||||
// 从 checkpoint 恢复状态(支持继续执行)
|
||||
const persisted = loadCheckpoint(sessionId);
|
||||
let state = persisted?.state ?? initialState;
|
||||
const pendingTasks = [...(persisted?.pendingTasks ?? initialTasks)];
|
||||
|
||||
// 主循环:直到有 stopReason
|
||||
while (!stopReason) {
|
||||
// 如果没有待执行任务,让 planner 规划新任务
|
||||
if (pendingTasks.length === 0) {
|
||||
const planned = planner.plan({ session, state, pendingTasks });
|
||||
pendingTasks.push(...planned);
|
||||
}
|
||||
|
||||
// 取出下一个任务
|
||||
const task = pendingTasks.shift();
|
||||
|
||||
// 执行任务
|
||||
const result = await executeTask(task, context);
|
||||
|
||||
// 处理执行结果
|
||||
if (result?.state) state = result.state; // 更新状态
|
||||
if (result?.prepend) pendingTasks.unshift(...result.prepend); // 前置任务
|
||||
if (result?.enqueue) pendingTasks.push(...result.enqueue); // 后置任务
|
||||
if (result?.stopReason) stopReason = result.stopReason; // 停止原因
|
||||
|
||||
// 保存 checkpoint(支持失败恢复)
|
||||
saveCheckpoint(sessionId, { state, pendingTasks, stopReason });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**3. 恢复机制** (`continueExisting`):
|
||||
- 从 SQLite 加载持久化的 checkpoint
|
||||
- 恢复 `state` 和 `pendingTasks`
|
||||
- **显式忽略**旧 checkpoint 的 `stopReason`,允许从 feedback 后继续
|
||||
- 不 replay events,直接继续执行
|
||||
|
||||
### 4.8.3 任务调度与决策
|
||||
|
||||
**Planner 是决策中枢**,根据当前 state 动态决定下一步:
|
||||
|
||||
```typescript
|
||||
private planTasks(context: KernelPlanningContext): KernelTask[] {
|
||||
// 阶段1: 前置条件检查(顺序执行)
|
||||
if (!context.state.workspacePath) {
|
||||
return [{ kind: 'skill', name: 'prepare_workspace' }];
|
||||
}
|
||||
if (!context.state.context) {
|
||||
return [{ kind: 'skill', name: 'build_context' }];
|
||||
}
|
||||
|
||||
// 阶段2: 上下文压缩决策
|
||||
if (shouldCompress(context)) {
|
||||
return [{ kind: 'skill', name: 'compress_context' }];
|
||||
}
|
||||
|
||||
// 阶段3: Triage 决策(生成自主审查提示)
|
||||
if (!context.state.triage) {
|
||||
return [{ kind: 'subagent', name: 'review:triage' }];
|
||||
}
|
||||
|
||||
// 阶段4: 单次完整自主审查
|
||||
if (!context.state.reviewCompleted) {
|
||||
return [{ kind: 'subagent', name: 'review:full_review' }];
|
||||
}
|
||||
|
||||
// 阶段5: 发布与收尾
|
||||
if (!context.state.published) {
|
||||
return [{ kind: 'skill', name: 'publish_review' }];
|
||||
}
|
||||
|
||||
return []; // 完成
|
||||
}
|
||||
```
|
||||
|
||||
**决策依据**:
|
||||
- **当前 State**: `triage`, `reviewCompleted`, `findings`, `published`, `reviewedRefSaved` 等字段
|
||||
- **Tags/Capabilities**: 按标签选择 subagent(`filterByTag('triage')`),非硬编码
|
||||
- **Config 开关**: 审查引擎、工作区、命令白名单等运行配置
|
||||
|
||||
### 4.8.4 Skills 与 Subagents 调用机制
|
||||
|
||||
**Skills - 原子任务**:
|
||||
|
||||
```typescript
|
||||
// 注册 Skills
|
||||
this.skillRegistry.register(createPrepareWorkspaceSkill());
|
||||
this.skillRegistry.register(createBuildContextSkill());
|
||||
|
||||
// Skill 定义
|
||||
{
|
||||
kind: 'skill',
|
||||
name: 'build_context',
|
||||
execute: async (task, context) => {
|
||||
// 执行业务逻辑
|
||||
const reviewContext = await diffExtractor.buildContext(...);
|
||||
|
||||
return {
|
||||
state: { ...context.state, context: reviewContext }, // 更新状态
|
||||
// 可选控制流
|
||||
prepend: [], // 在当前任务前插入新任务
|
||||
enqueue: [], // 在当前任务后追加新任务
|
||||
stopReason: undefined // 或 'completed', 'failed', 'awaiting_human_feedback'
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Subagents - 委派执行**:
|
||||
|
||||
```typescript
|
||||
// 调用路径
|
||||
AgentKernelRunner → KernelAgentInvoker.invoke(task, context)
|
||||
→ 创建 invocation record
|
||||
→ 执行 subagent.execute(task, agentContext)
|
||||
→ 完成 invocation,返回结果
|
||||
```
|
||||
|
||||
```typescript
|
||||
// Subagent 执行上下文
|
||||
const agentContext: KernelAgentExecutionContext = {
|
||||
...context,
|
||||
agent, // subagent 定义
|
||||
delegation: { // 委派包
|
||||
goal: agent.whenToUse,
|
||||
parentTaskName: task.name,
|
||||
input: task.input,
|
||||
contextSummary: state.compressedContext?.summary // 压缩摘要回注
|
||||
}
|
||||
};
|
||||
|
||||
// 执行(带 AsyncLocalStorage 隔离)
|
||||
const result = await runWithKernelAgentContext(
|
||||
{ agentId, parentSessionId, agentType: 'subagent', ... },
|
||||
() => agent.execute(task, agentContext)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.8.5 Tools 调用机制
|
||||
|
||||
**调用路径**(在 `review:full_review` 内部):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant FullReview as AutonomousReviewAgent
|
||||
participant Loop as Autonomous Loop
|
||||
participant Orchestration as ToolOrchestration
|
||||
participant Permission as Permission Gating
|
||||
participant Hook as PreToolUse Hook
|
||||
participant Tool as Tool.execute()
|
||||
participant PostHook as PostToolUse Hook
|
||||
|
||||
FullReview->>Loop: 决定调用 tool
|
||||
Loop->>Orchestration: partitionToolCalls(tools)
|
||||
Orchestration->>Permission: evaluateToolPermission(tool)
|
||||
Permission-->>Orchestration: allow/ask/deny
|
||||
Orchestration->>Hook: runKernelHooks(PreToolUse)
|
||||
Hook-->>Orchestration: additionalContext/updatedInput
|
||||
Orchestration->>Tool: tool.execute(args)
|
||||
Tool-->>Orchestration: result
|
||||
Orchestration->>PostHook: runKernelHooks(PostToolUse)
|
||||
PostHook-->>Orchestration: -
|
||||
Orchestration-->>Loop: toolResult
|
||||
Loop-->>FullReview: 更新 diagnostics/findings
|
||||
```
|
||||
|
||||
**并发控制**:
|
||||
- **并发安全工具** (`isConcurrencySafe: true`): 并行执行
|
||||
- **非并发安全工具**: 串行执行
|
||||
- **权限拦截**: `PermissionRequest` Hook 可批准/阻断
|
||||
|
||||
**权限边界**:
|
||||
|
||||
| Scope | 默认行为 | 说明 |
|
||||
|-------|----------|------|
|
||||
| `read` | `allow` | 安全操作(读文件、搜索代码) |
|
||||
| `write` | `ask` | 需审批(写文件) |
|
||||
| `command` | `ask` | 需审批(执行命令) |
|
||||
| `git_write` | `ask` | 需审批(Git 操作) |
|
||||
| `network` | `deny` | 禁止网络访问 |
|
||||
| `cross_session` | `deny` | 禁止跨 session 操作 |
|
||||
|
||||
### 4.8.6 代码审查结合流程
|
||||
|
||||
**完整数据流**:
|
||||
|
||||
```
|
||||
Webhook → PR/Commit
|
||||
↓
|
||||
prepare_workspace → 克隆仓库、准备 mirror/workspace
|
||||
↓
|
||||
build_context → 提取 diff、文件内容、构建 ReviewContext
|
||||
↓
|
||||
compress_context (可选) → 大上下文自动压缩,生成 summary
|
||||
↓
|
||||
review:triage → 生成自主审查提示、模式和预算
|
||||
↓
|
||||
review:full_review → 单个自主代理跨文件调查,生成 findings
|
||||
↓
|
||||
publish_review → 发布 summary + line comments
|
||||
↓
|
||||
save_reviewed_ref → 保存审查快照(支持增量审查)
|
||||
```
|
||||
|
||||
**状态流转**:
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> prepare_workspace: 启动
|
||||
prepare_workspace --> build_context: 成功
|
||||
build_context --> compress_context: 上下文过大
|
||||
build_context --> triage: 正常
|
||||
compress_context --> triage: 完成
|
||||
triage --> full_review: 提示生成完成
|
||||
full_review --> publish_review: findings 聚合完成
|
||||
publish_review --> save_reviewed_ref: 直接完成
|
||||
save_reviewed_ref --> [*]: completed
|
||||
```
|
||||
|
||||
### 4.8.7 边界划分
|
||||
|
||||
**Skills vs Subagents 边界**:
|
||||
|
||||
| 维度 | Skills | Subagents |
|
||||
|------|--------|-----------|
|
||||
| **粒度** | 原子操作(准备环境、构建上下文、发布) | 复杂推理(规划、完整审查) |
|
||||
| **模型** | 通常不涉及 LLM | 必须调用 LLM(planner/specialist) |
|
||||
| **并发** | 顺序执行 | 通过单个 full review 代理内部自主工具调用实现调查 |
|
||||
| **状态** | 修改 state 字段 | 可修改 state,主要产出 hints/findings/diagnostics |
|
||||
| **失败** | 阻断整个流程 | 可单独重试或降级 |
|
||||
| **示例** | prepare_workspace, publish_review | review:triage, review:full_review |
|
||||
|
||||
**Runtime vs Runner 边界**:
|
||||
|
||||
| 组件 | 职责 | 不做什么 |
|
||||
|------|------|----------|
|
||||
| **AgentKernelRunner** | 通用调度、checkpoint、task 循环 | 不感知 Review 业务逻辑 |
|
||||
| **ReviewKernelRuntime** | Review 业务封装、skills、subagents、hooks | 不直接调度任务(委托给 runner) |
|
||||
|
||||
**Subagents 间边界**:
|
||||
|
||||
| Subagent | 输入 | 输出 | 边界限制 |
|
||||
|----------|------|------|----------|
|
||||
| **triage** | ReviewContext | review hints + budget | 只生成提示,不审查 |
|
||||
| **full_review** | ReviewTask + context | findings[] + diagnostics | 一次完整自主审查,不预拆域或文件 |
|
||||
|
||||
**Hook 介入边界**:
|
||||
|
||||
```typescript
|
||||
// 在关键生命周期点介入
|
||||
SessionStart // session 启动时
|
||||
SubagentStart // subagent 启动时
|
||||
PreToolUse // 工具调用前(可修改输入、阻断)
|
||||
PermissionRequest // 权限请求时(决定 allow/ask/deny)
|
||||
PostToolUse // 工具调用成功后
|
||||
PostToolUseFailure // 工具调用失败后
|
||||
```
|
||||
|
||||
**Session 隔离边界**:
|
||||
|
||||
- 每个 PR/Commit 对应独立 session
|
||||
- session 间 state 不共享
|
||||
- tool 默认禁止 cross_session 操作
|
||||
- subagent invocation 绑定 parentSessionId
|
||||
|
||||
---
|
||||
|
||||
## 5. 运行时与状态设计
|
||||
|
||||
### 5.1 Session 与 Checkpoint
|
||||
|
||||
每条 PR/commit 审查对应一个 kernel session:
|
||||
|
||||
| 数据 | 用途 |
|
||||
|---|---|
|
||||
| `KernelSessionRecord` | 记录 scopeType、scopeKey、metadata、lastRunId |
|
||||
| `KernelSessionEventRecord` | append-only 事件流,记录 run/task/hook/feedback 生命周期 |
|
||||
| `KernelCheckpoint<TState>` | 持久化 state、pendingTasks、stopReason |
|
||||
| `KernelSubagentInvocationRecord` | 记录每次 subagent 委派调用 |
|
||||
|
||||
恢复语义:
|
||||
|
||||
- `continueExisting=true` 时从 persisted checkpoint 恢复 `state + pendingTasks`;
|
||||
- 显式忽略旧 checkpoint 的 stopReason,允许 feedback 后继续推进;
|
||||
- 当前不 replay session events 重建 state,event 主要用于投影与审计。
|
||||
|
||||
### 5.2 ReviewKernelState
|
||||
|
||||
核心状态包括:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `targetSha` | 当前审查目标 commit |
|
||||
| `mirrorPath/workspacePath` | 本地仓库与工作区路径 |
|
||||
| `context` | `ReviewContext`,包含 diff、changedFiles、fileContents 等 |
|
||||
| `projectPrompt` | 仓库级审查 prompt |
|
||||
| `compressedContext` | 自动压缩摘要及 token 元数据 |
|
||||
| `triage/reviewTask/reviewCompleted` | 自主审查提示、预算与完成状态 |
|
||||
| `findings` | subagents 收集到的问题 |
|
||||
| `reviewDiagnostics` | full review 工具调用、停止原因、解析计数等诊断信息 |
|
||||
| `published/reviewedRefSaved` | 发布与审查快照保存状态位 |
|
||||
|
||||
### 5.3 Subagent Invocation
|
||||
|
||||
每次 subagent 调用会持久化:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|---|---|
|
||||
| `parent_session_id` | 父 session |
|
||||
| `parent_run_id` | 当前 review run |
|
||||
| `parent_task_name` | 触发该调用的 task name |
|
||||
| `subagent_name` | subagent id,例如 `review:triage` |
|
||||
| `agent_id` | 本次调用唯一 agent identity |
|
||||
| `status` | running / completed / failed |
|
||||
| `input_json` | delegation packet |
|
||||
| `result_json` | structured invocation result |
|
||||
|
||||
失败处理:
|
||||
|
||||
- invoker 将 invocation 标记为 `failed`;
|
||||
- runner 写入 `task_failed` event;
|
||||
- checkpoint 保存当前 state 与 `[failedTask, ...pendingTasks]`,stopReason=`failed`;
|
||||
- 调用方可根据 checkpoint 与错误信息决定重试/人工介入。
|
||||
|
||||
### 5.4 上下文压缩与回注
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as Planner
|
||||
participant C as ContextCompressionService
|
||||
participant S as Session Checkpoint
|
||||
participant A as Subagent
|
||||
|
||||
P->>C: shouldCompress(context, compressedContext)
|
||||
C-->>P: true when tokenEstimate >= contextWindow * 0.8
|
||||
P->>C: compress(context, projectPrompt)
|
||||
C-->>S: compressedContext(summary, token stats, model, timestamp)
|
||||
P->>A: invoke subagent with contextSummary
|
||||
A-->>A: prompt includes compressed summary
|
||||
```
|
||||
|
||||
压缩触发阈值:
|
||||
|
||||
- 使用 `tokenCounter.getContextWindow(plannerModel)` 获取模型上下文窗口;
|
||||
- 取 80% 作为触发阈值,预留 20% 冗余;
|
||||
- 若无法获取模型配置,兜底使用默认窗口。
|
||||
|
||||
### 5.5 Hooks 与 Permission
|
||||
|
||||
内置 hooks:
|
||||
|
||||
| Hook | Event | 作用 |
|
||||
|---|---|---|
|
||||
| `kernel:session-start-audit` | `SessionStart` | 写入 `hook_session_start` event |
|
||||
| `kernel:subagent-start-audit` | `SubagentStart` | 写入 `hook_subagent_start` event |
|
||||
| `kernel:pre-tool-audit` | `PreToolUse` | 为工具调用追加审计上下文 |
|
||||
| `kernel:permission-request-audit` | `PermissionRequest` | 记录权限请求上下文 |
|
||||
|
||||
工具权限默认策略:
|
||||
|
||||
| Scope | 默认行为 |
|
||||
|---|---|
|
||||
| `read` | allow |
|
||||
| `write` | ask |
|
||||
| `command` | ask |
|
||||
| `git_write` | ask |
|
||||
| `network` | deny |
|
||||
| `cross_session` | deny |
|
||||
|
||||
---
|
||||
|
||||
## 6. API 与管理后台可观测性
|
||||
|
||||
### 6.1 Admin API
|
||||
|
||||
| API | 说明 |
|
||||
|---|---|
|
||||
| `GET /admin/api/review/sessions` | 返回 session 列表与 summary |
|
||||
| `GET /admin/api/review/sessions/:sessionId` | 返回 session、summary、checkpoint、plan、timeline、events、subagentInvocations、runDetails |
|
||||
| `GET /admin/api/review/kernel/tasks` | 返回 skill + subagent task catalog |
|
||||
| `GET /admin/api/review/kernel/subagents` | 返回 subagent catalog |
|
||||
| `GET /admin/api/review/kernel/hooks` | 返回 hook catalog |
|
||||
|
||||
### 6.2 Subagent Catalog 响应字段
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "subagent",
|
||||
"name": "review:full_review",
|
||||
"source": "built-in",
|
||||
"description": "执行一次完整自主代码审查",
|
||||
"whenToUse": "当 triage 生成审查提示后执行完整审查",
|
||||
"modelRole": "specialist",
|
||||
"tags": ["review", "specialist", "full-review", "autonomous-review"],
|
||||
"resumable": true
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 管理后台展示建议
|
||||
|
||||
管理后台应采用双层控制面:
|
||||
|
||||
- 上层:Kernel Subagents 目录,展示 built-in/custom/plugin subagents;
|
||||
- 下层:模型角色路由,配置 `planner / specialist` 到 provider/model。
|
||||
|
||||
展示字段建议:
|
||||
|
||||
| 区域 | 字段 |
|
||||
|---|---|
|
||||
| Subagent 目录 | name、source、description、whenToUse、modelRole、tags、resumable |
|
||||
| Session 详情 | summary、plan、timeline、findings、comments、subagentInvocations |
|
||||
| Invocation 详情 | agentId、status、startedAt、finishedAt、summary、artifacts |
|
||||
|
||||
---
|
||||
|
||||
## 7. 非功能性设计
|
||||
|
||||
### 7.1 安全设计
|
||||
|
||||
- 工具调用统一走 permission gating,避免 subagent 绕过权限策略;
|
||||
- 高风险工具默认 ask/deny,不允许直接执行网络、跨 session 或写操作;
|
||||
- hooks 可作为后续审批、审计、通知与策略扩展点;
|
||||
- LLM prompt 不作为安全边界,所有外部副作用必须由 tool/skill/adapters 承载。
|
||||
|
||||
### 7.2 高可用与恢复
|
||||
|
||||
- 每个 task 完成后保存 checkpoint,降低失败后的重复工作;
|
||||
- subagent invocation 失败会记录 failed 状态,便于定位失败代理;
|
||||
- feedback 后通过 `continueExisting` 从 checkpoint 继续;
|
||||
- publish 与 save reviewed ref 分离,避免评论发布与 ref 保存互相污染;
|
||||
- cleanup workspace 放在 runtime finally 中执行,降低资源泄漏风险。
|
||||
|
||||
### 7.3 可观测性
|
||||
|
||||
- session event 记录 run/task/hook/feedback 生命周期;
|
||||
- subagent invocation 记录 parent-child 委派关系;
|
||||
- admin projection 汇总 plan/timeline/currentStep/findingCount/pendingTaskCount;
|
||||
- compression 记录 sourceTokenEstimate、summaryTokenEstimate、triggerThreshold、model。
|
||||
|
||||
### 7.4 性能与容量
|
||||
|
||||
- 大 diff 先经 diff extractor/token budget 裁剪,再由 compression service 做会话级摘要;
|
||||
- `review:full_review` 在单个自主循环内使用工具逐步调查,避免运行时预拆 domain 或文件;
|
||||
- tool orchestration 可并发执行 read-only 工具,非并发安全工具串行;
|
||||
- session/event/checkpoint 使用 SQLite,适合当前单体部署;未来高并发可迁移到外部数据库。
|
||||
|
||||
### 7.5 可维护性与扩展性
|
||||
|
||||
- 新增内置 Agent 应只新增 `KernelSubagentDefinition` 并打 tags;
|
||||
- 新增流程副作用应优先实现 skill/adapters;
|
||||
- 新增横切逻辑应优先实现 hook;
|
||||
- 新增工具必须声明 permissionScope 和 isConcurrencySafe。
|
||||
|
||||
---
|
||||
|
||||
## 8. 测试与上线验证
|
||||
|
||||
### 8.1 自动化测试分层
|
||||
|
||||
| 层级 | 测试文件 | 覆盖点 |
|
||||
|---|---|---|
|
||||
| Unit | `src/review/kernel/__tests__/session-read-model.test.ts` | session summary/plan/timeline 投影 |
|
||||
| Unit | `src/review/tools/__tests__/tool-permissions.test.ts` | permission scope 默认策略 |
|
||||
| Contract | `src/agent-kernel/hooks/__tests__/kernel-hook-runner.test.ts` | hook 聚合、approve/block、updatedInput |
|
||||
| Integration | `src/controllers/__tests__/admin-review-sessions.test.ts` | admin session 与 catalog API |
|
||||
| Integration | `src/controllers/__tests__/feedback-kernel-session.test.ts` | feedback approve/reject/rollback/continue |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-happy-path.test.ts` | 完整 runtime happy path |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-feedback-resume.test.ts` | awaiting feedback 后恢复 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-replay-invariants.test.ts` | checkpoint/resume/replay 不变量 |
|
||||
| Runtime | `src/review/kernel/__tests__/runtime-concurrency-idempotency.test.ts` | 并发上限与幂等 |
|
||||
| Canary | `src/review/kernel/__tests__/compression-resumability.test.ts` | 压缩恢复与生产关键 canary |
|
||||
|
||||
### 8.2 上线前门禁
|
||||
|
||||
必须通过:
|
||||
|
||||
```bash
|
||||
bun run lint
|
||||
bun run build
|
||||
bun test src/review/kernel/__tests__ src/review/tools/__tests__ src/controllers/__tests__ src/agent-kernel/hooks/__tests__
|
||||
bun test
|
||||
```
|
||||
|
||||
关键验收信号:
|
||||
|
||||
- runtime happy path 完成,stopReason=`completed`;
|
||||
- feedback resume 从 `awaiting_human_feedback` 恢复到 completed;
|
||||
- compression resume 保留 targetSha、pending boundary、invocation boundary、summary;
|
||||
- permission deny 不会绕过工具治理;
|
||||
- duplicate enqueue/continue/feedback 不产生重复有效工作;
|
||||
- admin session detail 能看到 plan/timeline/subagentInvocations。
|
||||
|
||||
### 8.3 灰度与回滚
|
||||
|
||||
- 配置默认:`REVIEW_ENGINE=kernel`;
|
||||
- 若需要回滚,可临时切到 `codex` 引擎,但旧固定 agent 编排不再作为主路径;
|
||||
- 灰度期间重点观察 session stopReason 分布、task_failed 事件、subagent failed invocations、feedback resume 成功率。
|
||||
|
||||
---
|
||||
|
||||
## 9. 风险、待确认与后续演进
|
||||
|
||||
### 9.1 风险与应对
|
||||
|
||||
| 风险 | 影响 | 应对 |
|
||||
|---|---|---|
|
||||
| Built-in definitions 仍在代码中 | 扩展仍需发版 | 下一阶段引入 plugin/custom subagent loader |
|
||||
| SQLite 单文件并发能力有限 | 高并发 session 下写入竞争 | 当前单体可接受;未来迁移外部 DB 或队列化写入 |
|
||||
| Compression summary 可能遗漏细节 | 后续 subagent 判断偏差 | 保留 recent context + summary;测试锁定关键事实不丢 |
|
||||
| Hook 阻断策略过强或过弱 | 工具误阻断或越权 | permission matrix 测试 + 审计 event + 管理后台策略展示 |
|
||||
|
||||
### 9.2 后续演进计划
|
||||
|
||||
1. **Plugin-based Subagent Loading**:支持从目录或配置加载 custom/plugin subagents。
|
||||
2. **Child Session Tree**:为长任务或后台 subagent 引入 child session/resume tree。
|
||||
3. **Attachment Reinjection**:压缩后恢复文件附件、计划附件和技能附件。
|
||||
4. **更细粒度权限模型**:支持仓库级、工具级、用户级策略配置。
|
||||
5. **Subagent 版本治理**:为 built-in/custom/plugin subagents 增加 version、enabled、rollout 字段。
|
||||
|
||||
### 9.3 评审清单
|
||||
|
||||
- [ ] 内置 Agent 是否都通过 registry/invoker 调用,而不是 runtime 硬编码实例?
|
||||
- [ ] planner 是否按 tag/capability 选择 subagent?
|
||||
- [ ] 每次 subagent 调用是否有 invocation record?
|
||||
- [ ] feedback 后 continue 是否从 checkpoint 恢复?
|
||||
- [ ] 压缩 summary 是否持久化并回注 triage/full_review?
|
||||
- [ ] 工具执行是否经过 permission/hook/orchestration?
|
||||
- [ ] 管理后台是否能展示 catalog、timeline、invocations?
|
||||
- [ ] 生产测试门禁是否覆盖 happy path、失败恢复、幂等和 canary?
|
||||
|
||||
---
|
||||
|
||||
## 版本记录
|
||||
|
||||
| 版本 | 日期 | 说明 |
|
||||
|---|---|---|
|
||||
| v0.1 | 2026-04-28 | 初版:记录 Kernel 内置 Agent 架构、运行链路、可观测性与测试门禁 |
|
||||
@@ -1,617 +0,0 @@
|
||||
# 通知服务抽象化重构方案
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 背景
|
||||
当前项目中的通知功能仅支持飞书(Feishu/Lark)平台,代码高度耦合飞书特定的API实现。随着业务需求扩展,需要支持企业微信(WeCom)等其他通知渠道。
|
||||
|
||||
### 1.2 目标
|
||||
- 抽象通用通知服务接口,支持多平台扩展
|
||||
- 支持同时配置多个通知服务(如飞书+企业微信同时推送)
|
||||
- 统一通知调用入口,避免平台耦合与重复发送
|
||||
- 清晰的代码结构,便于后续添加新平台(如Slack、钉钉等)
|
||||
|
||||
### 1.3 非目标
|
||||
- 不修改通知的业务触发逻辑
|
||||
- 不改变现有的Gitea Webhook处理流程
|
||||
- 不引入外部通知服务SDK依赖(保持轻量)
|
||||
|
||||
---
|
||||
|
||||
## 2. 现有架构分析
|
||||
|
||||
### 2.1 重构前实现(已下线)
|
||||
```
|
||||
src/
|
||||
├── services/feishu.ts # 飞书服务实现(156行)
|
||||
├── controllers/review.ts # 通知调用点
|
||||
├── config/config-schema.ts # 配置定义
|
||||
└── config/config-manager.ts # 配置管理
|
||||
```
|
||||
|
||||
### 2.2 关键代码特征
|
||||
- **强耦合**:`review.ts` 直接调用 `feishuService.sendXXXNotification()`
|
||||
- **硬编码消息格式**:飞书特定的 `msg_type: 'text'` 结构
|
||||
- **签名逻辑**:HMAC-SHA256(timestamp+"\n"+secret)
|
||||
- **配置单一**:仅支持一组飞书配置
|
||||
|
||||
### 2.3 通知场景
|
||||
| 场景 | 方法名 | 触发条件 |
|
||||
|------|--------|----------|
|
||||
| 工单创建 | `sendIssueCreatedNotification` | Issue opened + 有指派人 |
|
||||
| 工单关闭 | `sendIssueClosedNotification` | Issue closed |
|
||||
| 工单指派 | `sendIssueAssignedNotification` | Issue assigned |
|
||||
| PR创建 | `sendPrCreatedNotification` | PR opened + 有审阅者 |
|
||||
| PR指派 | `sendPrReviewerAssignedNotification` | PR review_requested |
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构设计
|
||||
|
||||
### 3.1 架构模式
|
||||
采用**策略模式(Strategy)** + **工厂模式(Factory)**:
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Notification Service Layer │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │
|
||||
│ │ INotification │ │ INotification │ │ INotification│ │
|
||||
│ │ Service │ │ Service │ │ Service │ │
|
||||
│ │ (Interface) │ │ (Interface) │ │ (Interface) │ │
|
||||
│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌─────┴─────┐ ┌────┴────┐ ┌────┴────┐ │
|
||||
│ │ Feishu │ │ WeCom │ │ Slack │ │
|
||||
│ │Service │ │Service │ │ Service │ │
|
||||
│ └───────────┘ └─────────┘ └─────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationFactory│
|
||||
└─────────┬─────────┘
|
||||
│
|
||||
┌─────────┴─────────┐
|
||||
│ NotificationManager│ ← 统一入口,支持多服务
|
||||
└───────────────────┘
|
||||
```
|
||||
|
||||
### 3.2 核心接口设计
|
||||
|
||||
#### 3.2.1 类型定义
|
||||
```typescript
|
||||
// types.ts
|
||||
export type NotificationProvider = 'feishu' | 'wecom' | 'slack' | 'dingtalk';
|
||||
|
||||
export interface NotificationContext {
|
||||
// PR相关
|
||||
prTitle?: string;
|
||||
prUrl?: string;
|
||||
prNumber?: number;
|
||||
|
||||
// Issue相关
|
||||
issueTitle?: string;
|
||||
issueUrl?: string;
|
||||
issueNumber?: number;
|
||||
|
||||
// 用户相关
|
||||
actor?: string;
|
||||
assignees?: string[];
|
||||
reviewers?: string[];
|
||||
creator?: string;
|
||||
|
||||
// 仓库相关
|
||||
repository?: string;
|
||||
owner?: string;
|
||||
|
||||
// 时间戳
|
||||
timestamp?: Date;
|
||||
}
|
||||
|
||||
export interface NotificationMessage {
|
||||
type: 'text' | 'markdown';
|
||||
title?: string;
|
||||
content: string;
|
||||
atUsers?: string[];
|
||||
url?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 服务接口
|
||||
```typescript
|
||||
// INotificationService
|
||||
export interface INotificationService {
|
||||
readonly provider: NotificationProvider;
|
||||
|
||||
isEnabled(): boolean;
|
||||
sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 场景特定方法
|
||||
sendIssueCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueClosedNotification(context: NotificationContext): Promise<void>;
|
||||
sendIssueAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrCreatedNotification(context: NotificationContext): Promise<void>;
|
||||
sendPrReviewerAssignedNotification(context: NotificationContext): Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 平台差异对照
|
||||
|
||||
| 特性 | 飞书(Feishu) | 企业微信(WeCom) | Slack |
|
||||
|------|--------------|-----------------|-------|
|
||||
| **Webhook格式** | `open.feishu.cn/open-apis/bot/v2/hook/{key}` | `qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}` | `hooks.slack.com/services/...` |
|
||||
| **签名机制** | HMAC-SHA256(timestamp+"\n"+secret) | **无** | HMAC-SHA256(timestamp+":"+secret) |
|
||||
| **@用户方式** | `<at user_id="xxx">` 或文本追加 | `mentioned_list: ["userid"]` 或手机号 | `<@user_id>` |
|
||||
| **消息类型字段** | `msg_type` | `msgtype` | `type` |
|
||||
| **内容字段** | `content.text` | `text.content` | `text` |
|
||||
| **频率限制** | 100次/分钟 | 20条/分钟 | 1次/秒 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 详细实现方案
|
||||
|
||||
### 4.1 目录结构
|
||||
```
|
||||
src/
|
||||
├── services/
|
||||
│ ├── notification/
|
||||
│ │ ├── index.ts # 导出入口
|
||||
│ │ ├── types.ts # 类型定义
|
||||
│ │ ├── base-notification-service.ts # 抽象基类
|
||||
│ │ ├── notification-factory.ts # 工厂
|
||||
│ │ ├── notification-manager.ts # 管理器
|
||||
│ │ └── providers/
|
||||
│ │ ├── feishu-notification-service.ts
|
||||
│ │ └── wecom-notification-service.ts
|
||||
│ └── notification-manager.ts # 运行时通知管理器入口
|
||||
```
|
||||
|
||||
### 4.2 基类实现
|
||||
|
||||
```typescript
|
||||
// base-notification-service.ts
|
||||
export abstract class BaseNotificationService implements INotificationService {
|
||||
abstract readonly provider: NotificationProvider;
|
||||
|
||||
constructor(protected config: NotificationServiceConfig) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled && !!this.config.webhookUrl;
|
||||
}
|
||||
|
||||
abstract sendMessage(message: NotificationMessage): Promise<void>;
|
||||
|
||||
// 通用模板方法
|
||||
async sendIssueCreatedNotification(context: NotificationContext): Promise<void> {
|
||||
const message = this.buildIssueCreatedMessage(context);
|
||||
await this.sendMessage(message);
|
||||
}
|
||||
|
||||
// 子类实现消息构建
|
||||
protected abstract buildIssueCreatedMessage(context: NotificationContext): NotificationMessage;
|
||||
// ... 其他方法类似
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 飞书实现要点
|
||||
|
||||
```typescript
|
||||
// feishu-notification-service.ts
|
||||
export class FeishuNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'feishu' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 添加签名
|
||||
if (this.config.webhookSecret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||
payload.timestamp = timestamp;
|
||||
payload.sign = this.generateSign(timestamp, this.config.webhookSecret);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Feishu notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
|
||||
private generateSign(timestamp: string, secret: string): string {
|
||||
const stringToSign = `${timestamp}\n${secret}`;
|
||||
const hmac = crypto.createHmac('sha256', stringToSign);
|
||||
return hmac.digest('base64');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 企业微信实现要点
|
||||
|
||||
```typescript
|
||||
// wecom-notification-service.ts
|
||||
export class WeComNotificationService extends BaseNotificationService {
|
||||
readonly provider = 'wecom' as const;
|
||||
|
||||
async sendMessage(message: NotificationMessage): Promise<void> {
|
||||
const payload: any = {
|
||||
msgtype: 'text',
|
||||
text: {
|
||||
content: message.content,
|
||||
},
|
||||
};
|
||||
|
||||
// 企业微信使用 mentioned_list
|
||||
if (message.atUsers?.length) {
|
||||
payload.text.mentioned_list = message.atUsers.map(u =>
|
||||
u.toLowerCase() === 'all' ? '@all' : u
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(this.config.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`WeCom notification failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
protected buildIssueCreatedMessage(context: NotificationContext): NotificationMessage {
|
||||
return {
|
||||
type: 'text',
|
||||
content: `📝 新工单已创建\n标题: ${context.issueTitle}\n链接: ${context.issueUrl}`,
|
||||
atUsers: context.assignees,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 管理器实现
|
||||
|
||||
```typescript
|
||||
// notification-manager.ts
|
||||
export class NotificationManager {
|
||||
private services: INotificationService[] = [];
|
||||
|
||||
constructor(configs: NotificationServiceConfig[]) {
|
||||
this.services = configs
|
||||
.filter(c => c.enabled && c.webhookUrl)
|
||||
.map(c => NotificationFactory.createService(c));
|
||||
}
|
||||
|
||||
// 广播到所有服务
|
||||
async broadcast(
|
||||
operation: (service: INotificationService) => Promise<void>
|
||||
): Promise<void> {
|
||||
const results = await Promise.allSettled(
|
||||
this.services.map(async service => {
|
||||
try {
|
||||
await operation(service);
|
||||
} catch (error) {
|
||||
logger.error(`${service.provider} notification failed:`, error);
|
||||
throw error; // 重新抛出以便Promise.allSettled捕获
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 记录失败统计
|
||||
const failures = results.filter(r => r.status === 'rejected');
|
||||
if (failures.length > 0) {
|
||||
logger.warn(`${failures.length}/${this.services.length} notification services failed`);
|
||||
}
|
||||
}
|
||||
|
||||
// 便捷方法
|
||||
async notifyIssueCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueClosed(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueClosedNotification(context));
|
||||
}
|
||||
|
||||
async notifyIssueAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendIssueAssignedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrCreated(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrCreatedNotification(context));
|
||||
}
|
||||
|
||||
async notifyPrReviewerAssigned(context: NotificationContext): Promise<void> {
|
||||
await this.broadcast(s => s.sendPrReviewerAssignedNotification(context));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 配置改造
|
||||
|
||||
### 5.1 新增配置字段
|
||||
|
||||
```typescript
|
||||
// config-schema.ts
|
||||
export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
// ... 保留原有 ...
|
||||
|
||||
// 飞书配置(改造为可独立启用)
|
||||
{
|
||||
envKey: 'FEISHU_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用飞书通知',
|
||||
description: '是否启用飞书通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 地址',
|
||||
description: '飞书机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEISHU_WEBHOOK_SECRET',
|
||||
group: 'notification',
|
||||
label: '飞书 Webhook 密钥',
|
||||
description: '飞书 Webhook 签名密钥(可选)',
|
||||
type: 'string',
|
||||
sensitive: true,
|
||||
},
|
||||
|
||||
// 企业微信配置(新增)
|
||||
{
|
||||
envKey: 'WECOM_ENABLED',
|
||||
group: 'notification',
|
||||
label: '启用企业微信通知',
|
||||
description: '是否启用企业微信通知',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'WECOM_WEBHOOK_URL',
|
||||
group: 'notification',
|
||||
label: '企业微信 Webhook 地址',
|
||||
description: '企业微信机器人 Webhook URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### 5.2 配置组调整
|
||||
|
||||
```typescript
|
||||
// 将 'feishu' 组改为 'notification' 组
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
|
||||
export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
// ...
|
||||
{
|
||||
key: 'notification',
|
||||
label: '通知服务',
|
||||
description: '飞书、企业微信等通知渠道配置',
|
||||
icon: 'bell',
|
||||
},
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 调用层迁移
|
||||
|
||||
### 6.1 review.ts 改造
|
||||
|
||||
```typescript
|
||||
import { getNotificationManager } from '../services/notification-manager';
|
||||
|
||||
// PR事件处理
|
||||
async function handlePullRequestEvent(c: Context, body: any): Promise<Response> {
|
||||
// ... 原有逻辑 ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
prTitle: pullRequest.title,
|
||||
prUrl: pullRequest.html_url,
|
||||
prNumber: pullRequest.number,
|
||||
reviewers: reviewerUsernames,
|
||||
repository: repo.name,
|
||||
owner: repo.owner.login,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
const notificationManager = getNotificationManager();
|
||||
|
||||
if (body.action === 'opened' && reviewerUsernames.length > 0) {
|
||||
await notificationManager.notifyPrCreated(context);
|
||||
}
|
||||
|
||||
if (body.action === 'review_requested' && body.requested_reviewer) {
|
||||
context.assignees = [body.requested_reviewer.full_name || body.requested_reviewer.login];
|
||||
await notificationManager.notifyPrReviewerAssigned(context);
|
||||
}
|
||||
|
||||
// ... 继续原有逻辑 ...
|
||||
}
|
||||
|
||||
// Issue事件处理
|
||||
async function handleIssueEvent(c: Context, body: any): Promise<Response> {
|
||||
// ...
|
||||
|
||||
const context: NotificationContext = {
|
||||
issueTitle: issue.title,
|
||||
issueUrl: issue.html_url,
|
||||
issueNumber: issue.number,
|
||||
creator: creatorUsername,
|
||||
assignees: assigneeUsernames,
|
||||
repository: repository.name,
|
||||
actor: body.sender?.login,
|
||||
};
|
||||
|
||||
if (action === 'opened' && assigneeUsernames.length > 0) {
|
||||
await notificationManager.notifyIssueCreated(context);
|
||||
} else if (action === 'closed') {
|
||||
await notificationManager.notifyIssueClosed(context);
|
||||
} else if (action === 'assigned') {
|
||||
await notificationManager.notifyIssueAssigned(context);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 落地决策(已执行)
|
||||
|
||||
### 7.1 旧飞书服务下线
|
||||
|
||||
- 已删除 `src/services/feishu.ts`,不再保留兼容层。
|
||||
- `src/controllers/review.ts` 中所有通知发送路径已统一到 `NotificationManager`。
|
||||
- 通过单一通知入口避免重复发送与配置路径分裂问题。
|
||||
|
||||
### 7.2 运行时配置生效策略
|
||||
|
||||
- 通知管理器按当前配置即时创建,不再使用长生命周期缓存。
|
||||
- 后台保存通知配置后,可立即在后续 webhook 事件生效。
|
||||
|
||||
### 7.3 落地检查清单
|
||||
|
||||
- [x] 飞书与企业微信通过统一通知抽象发送
|
||||
- [x] 旧飞书服务文件已下线
|
||||
- [x] 控制器通知链路已去重
|
||||
- [x] 前端新增独立“通知管理”菜单与页面
|
||||
|
||||
---
|
||||
|
||||
## 8. 实施计划
|
||||
|
||||
### 8.1 阶段划分
|
||||
|
||||
| 阶段 | 任务 | 文件 | 优先级 |
|
||||
|------|------|------|--------|
|
||||
| 1 | 核心抽象层 | `types.ts`, `base-notification-service.ts` | P0 |
|
||||
| 2 | 工厂与管理器 | `notification-factory.ts`, `notification-manager.ts` | P0 |
|
||||
| 3 | 飞书实现 | `providers/feishu-notification-service.ts` | P1 |
|
||||
| 4 | 企业微信实现 | `providers/wecom-notification-service.ts` | P1 |
|
||||
| 5 | 配置改造 | `config-schema.ts`, `config-manager.ts` | P1 |
|
||||
| 6 | 调用层迁移 | `review.ts` | P1 |
|
||||
| 7 | 前端通知管理页面 | `App.tsx`, `DashboardPage.tsx`, `NotificationConfigPage.tsx` | P1 |
|
||||
| 8 | 测试验证 | `config-manager.test.ts` 等 | P0 |
|
||||
|
||||
### 8.2 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| 签名算法变更 | 飞书通知失效 | 保持原有签名实现,单元测试覆盖 |
|
||||
| 配置迁移失败 | 服务无法启动 | 添加配置验证和默认值回退 |
|
||||
| 多服务并发失败 | 部分通知丢失 | Promise.allSettled 确保独立性 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
### 9.1 单元测试
|
||||
|
||||
```typescript
|
||||
// __tests__/notification.test.ts
|
||||
describe('NotificationService', () => {
|
||||
describe('FeishuNotificationService', () => {
|
||||
it('should generate correct signature', () => {
|
||||
// 测试签名算法
|
||||
});
|
||||
|
||||
it('should format message correctly', () => {
|
||||
// 测试消息格式转换
|
||||
});
|
||||
});
|
||||
|
||||
describe('WeComNotificationService', () => {
|
||||
it('should use mentioned_list for @users', () => {
|
||||
// 测试@用户格式
|
||||
});
|
||||
});
|
||||
|
||||
describe('NotificationManager', () => {
|
||||
it('should broadcast to all enabled services', async () => {
|
||||
// 测试广播逻辑
|
||||
});
|
||||
|
||||
it('should not fail if one service fails', async () => {
|
||||
// 测试容错
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 9.2 集成测试
|
||||
|
||||
- 配置真实飞书机器人测试消息发送
|
||||
- 配置企业微信机器人测试消息发送
|
||||
- 验证同时配置多个服务时的行为
|
||||
|
||||
---
|
||||
|
||||
## 10. 附录
|
||||
|
||||
### 10.1 飞书与企业微信API对比详情
|
||||
|
||||
#### 飞书消息格式
|
||||
```json
|
||||
{
|
||||
"msg_type": "text",
|
||||
"content": {
|
||||
"text": "Hello <at user_id=\"ou_xxx\">Tom</at>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 企业微信消息格式
|
||||
```json
|
||||
{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "Hello World",
|
||||
"mentioned_list": ["wangqing", "@all"],
|
||||
"mentioned_mobile_list": ["13800001111"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 10.2 扩展指南
|
||||
|
||||
添加新通知平台步骤:
|
||||
|
||||
1. 在 `types.ts` 添加新的 `NotificationProvider` 类型
|
||||
2. 在 `providers/` 创建新的服务类,继承 `BaseNotificationService`
|
||||
3. 在 `notification-factory.ts` 添加创建逻辑
|
||||
4. 在 `config-schema.ts` 添加配置字段
|
||||
5. 在 Admin Dashboard 添加UI配置项
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**创建日期**: 2026-03-24
|
||||
**作者**: Sisyphus
|
||||
**状态**: 已实施(持续验证中)
|
||||
@@ -1,836 +0,0 @@
|
||||
# 技术设计文档:可插拔 LLM Provider 架构
|
||||
|
||||
> **状态**: Draft
|
||||
> **作者**: AI Architect
|
||||
> **日期**: 2026-03-04
|
||||
> **相关 Issue**: N/A
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [0. 设计原则](#0-设计原则)
|
||||
- [1. 目录结构](#1-目录结构新增改动部分)
|
||||
- [2. 数据库表结构](#2-数据库表结构sqlite-ddl)
|
||||
- [3. LLM Gateway 核心接口](#3-llm-gateway-核心-typescript-接口)
|
||||
- [4. Provider Adapter 差异映射](#4-四个-provider-adapter-核心差异映射)
|
||||
- [5. 后端 REST API 契约](#5-后端-rest-api-契约)
|
||||
- [6. 密钥安全设计](#6-密钥安全设计)
|
||||
- [7. 前端配置页设计](#7-前端配置页设计)
|
||||
- [8. 现有调用点改造清单](#8-现有调用点改造清单)
|
||||
- [9. 实施阶段建议](#9-实施阶段建议)
|
||||
- [10. 风险与缓解](#10-风险与缓解)
|
||||
|
||||
---
|
||||
|
||||
## 0. 设计原则
|
||||
|
||||
| 原则 | 说明 |
|
||||
|---|---|
|
||||
| **UI-Only 配置** | 所有业务配置仅通过 Web 管理后台设置,不再有环境变量覆盖层(仅保留极少数启动参数如 `PORT`、`WEBHOOK_SECRET`、`DATABASE_PATH`) |
|
||||
| **4 Provider 并存** | `openai_compatible`(现有兼容格式)、`openai_responses`(Responses API)、`anthropic`(Messages API)、`gemini`(generateContent API) |
|
||||
| **SQLite 持久化** | 使用 `bun:sqlite` 零依赖,单文件 `data/assistant.db` |
|
||||
| **密钥应用层加密** | API Key 使用 AES-256-GCM 加密后存 DB;主密钥通过环境变量 `ENCRYPTION_KEY` 传入(hex 编码,64 字符 = 32 字节),未设置则拒绝启动 |
|
||||
| **不做向前兼容** | 旧 JSON 配置文件方案直接废弃,新版本仅支持数据库配置 |
|
||||
|
||||
### 开源参考
|
||||
|
||||
| 借鉴点 | 参考项目 | 具体模式 |
|
||||
|---|---|---|
|
||||
| 接口先行 + provider registry | [Vercel AI SDK](https://github.com/vercel/ai) | `LanguageModelV3` spec-first adapter,版本化接口 |
|
||||
| Provider transformation 差异映射 | [LiteLLM](https://github.com/BerriAI/litellm) | 每个 provider 独立 `transformation.py`,标准化 error/usage |
|
||||
| Runtime factory + 多 provider 配置 | [LobeChat](https://github.com/lobehub/lobe-chat) | `createOpenAICompatibleRuntime` 工厂 + 动态 model list |
|
||||
| 配置驱动多 endpoint | [LibreChat](https://github.com/danny-avila/LibreChat) | `librechat.yaml` Custom Endpoints 模式 |
|
||||
| 能力声明/能力检测 | [Continue](https://github.com/continuedev/continue) | `supportsTools` / `supportsImages` 声明式 capability |
|
||||
|
||||
---
|
||||
|
||||
## 1. 目录结构(新增/改动部分)
|
||||
|
||||
```
|
||||
src/
|
||||
├── db/
|
||||
│ ├── database.ts # bun:sqlite 初始化
|
||||
│ ├── migrations/
|
||||
│ │ └── 001_init.ts # 建表 DDL
|
||||
│ └── repositories/
|
||||
│ ├── provider-repo.ts # llm_providers CRUD
|
||||
│ ├── model-role-repo.ts # model_role_assignments CRUD
|
||||
│ ├── secret-repo.ts # 加密 read/write
|
||||
│ └── settings-repo.ts # system_settings KV
|
||||
│
|
||||
├── llm/
|
||||
│ ├── types.ts # 统一内部请求/响应类型
|
||||
│ ├── capabilities.ts # 能力声明枚举
|
||||
│ ├── errors.ts # LLM 层标准化错误
|
||||
│ ├── gateway.ts # LLM Gateway 入口(按 provider 路由)
|
||||
│ ├── tool-converter.ts # 工具定义 → 各 provider 格式转换
|
||||
│ └── providers/
|
||||
│ ├── base.ts # LLMProvider 抽象接口
|
||||
│ ├── openai-compatible.ts # 现有兼容格式 adapter
|
||||
│ ├── openai-responses.ts # OpenAI Responses API adapter
|
||||
│ ├── anthropic.ts # Anthropic Messages API adapter
|
||||
│ └── gemini.ts # Gemini generateContent adapter
|
||||
│
|
||||
├── crypto/
|
||||
│ └── secrets.ts # AES-256-GCM 加解密 + master key 管理
|
||||
│
|
||||
├── controllers/
|
||||
│ └── llm-config.ts # 新 REST API(替代 config.ts 中 LLM 部分)
|
||||
│
|
||||
└── config/
|
||||
├── config-manager.ts # 精简:只管非 LLM 配置(gitea/feishu/app/admin/review 非模型部分)
|
||||
└── config-schema.ts # 移除 openai group,LLM 配置全部走 DB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据库表结构(SQLite DDL)
|
||||
|
||||
### 2.1 ER 关系
|
||||
|
||||
```
|
||||
llm_providers 1 ←──── 1 llm_secrets (每个 provider 一个加密 key)
|
||||
llm_providers 1 ←──── N model_role_assignments (一个 provider 可服务多个角色)
|
||||
```
|
||||
|
||||
### 2.2 完整 DDL
|
||||
|
||||
```sql
|
||||
-- ============================================================
|
||||
-- 表1: llm_providers — Provider 实例配置
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_providers (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(8)))),
|
||||
name TEXT NOT NULL, -- 用户自定义显示名,如 "公司内部 OpenAI 代理"
|
||||
type TEXT NOT NULL CHECK (type IN (
|
||||
'openai_compatible', -- 现有兼容格式(自定义 baseUrl)
|
||||
'openai_responses', -- OpenAI 标准 Responses API
|
||||
'anthropic', -- Anthropic Messages API
|
||||
'gemini' -- Google Gemini generateContent
|
||||
)),
|
||||
base_url TEXT, -- 可选自定义 endpoint(openai_compatible 必填)
|
||||
default_model TEXT NOT NULL, -- 此 provider 的默认模型 ID
|
||||
is_enabled INTEGER NOT NULL DEFAULT 1, -- 0=禁用, 1=启用
|
||||
extra_config TEXT DEFAULT '{}', -- JSON: provider 特有配置(如 api_version, project_id 等)
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表2: llm_secrets — 加密存储的 API Key
|
||||
-- ============================================================
|
||||
CREATE TABLE llm_secrets (
|
||||
provider_id TEXT PRIMARY KEY REFERENCES llm_providers(id) ON DELETE CASCADE,
|
||||
ciphertext BLOB NOT NULL, -- AES-256-GCM 密文
|
||||
iv BLOB NOT NULL, -- 12 bytes nonce
|
||||
auth_tag BLOB NOT NULL, -- 16 bytes GCM tag
|
||||
key_version INTEGER NOT NULL DEFAULT 1, -- 主密钥版本号(便于密钥轮换)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表3: model_role_assignments — 场景 → 模型映射
|
||||
-- ============================================================
|
||||
-- 每个业务场景(如 planner/specialist/judge/embedding)绑定到
|
||||
-- 一个 provider + 具体 model,支持不同场景用不同 provider。
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner', -- Agent 审查 planner
|
||||
'specialist', -- Agent 审查 specialist
|
||||
'judge', -- Agent 审查 judge
|
||||
'embedding' -- 向量嵌入(Qdrant)
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL, -- 该场景使用的具体模型 ID
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- ============================================================
|
||||
-- 表4: system_settings — 通用 KV 设置
|
||||
-- ============================================================
|
||||
-- 存放非 LLM 的业务配置(由 UI 直接写入 DB)
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
is_sensitive INTEGER NOT NULL DEFAULT 0, -- 1=加密存储(复用 crypto 模块)
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- 索引
|
||||
CREATE INDEX idx_providers_type ON llm_providers(type);
|
||||
CREATE INDEX idx_providers_enabled ON llm_providers(is_enabled);
|
||||
```
|
||||
|
||||
### 2.3 字段说明补充
|
||||
|
||||
| 表.字段 | 说明 |
|
||||
|---|---|
|
||||
| `llm_providers.type` | 决定使用哪个 adapter 实现 |
|
||||
| `llm_providers.base_url` | `openai_compatible` 类型必填(用户自建代理地址);其他类型可选覆盖官方默认 endpoint |
|
||||
| `llm_providers.extra_config` | JSON 字段,存放 provider 特有参数。例如 Gemini 的 `projectId`、OpenAI 的 `organization`、Anthropic 的 `anthropic-version` header 等 |
|
||||
| `llm_secrets.key_version` | 用于密钥轮换:当 `ENCRYPTION_KEY` 更新后,启动时批量重加密所有 `key_version < current` 的记录 |
|
||||
| `model_role_assignments.role` | 业务角色枚举,对应代码中不同调用场景 |
|
||||
| `system_settings.is_sensitive` | 为 1 时 value 字段存密文(复用 `crypto/secrets.ts`),GET API 返回 masked |
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM Gateway 核心 TypeScript 接口
|
||||
|
||||
### 3.1 统一消息与请求/响应类型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/types.ts ────────────────────────────────────────
|
||||
|
||||
/** 模型角色枚举 */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
/** 统一消息格式(内部表达,不暴露 provider 差异) */
|
||||
export interface LLMMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string;
|
||||
toolCallId?: string; // role=tool 时关联的 tool call ID
|
||||
toolCalls?: LLMToolCall[]; // role=assistant 时返回的 tool calls
|
||||
}
|
||||
|
||||
export interface LLMToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string; // JSON string
|
||||
}
|
||||
|
||||
/** 工具定义(内部通用格式,由 tool-converter.ts 转为各 provider 格式) */
|
||||
export interface LLMToolDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>; // JSON Schema
|
||||
}
|
||||
|
||||
/** 统一请求 */
|
||||
export interface LLMChatRequest {
|
||||
messages: LLMMessage[];
|
||||
model: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json'; // 抽象 JSON mode
|
||||
tools?: LLMToolDefinition[];
|
||||
/** provider 透传配置(如 Anthropic thinking、Gemini safetySettings) */
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 统一响应 */
|
||||
export interface LLMChatResponse {
|
||||
content: string | null;
|
||||
toolCalls: LLMToolCall[];
|
||||
finishReason: 'stop' | 'tool_calls' | 'length' | 'content_filter' | 'error';
|
||||
usage: {
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
totalTokens: number;
|
||||
};
|
||||
raw?: unknown; // 保留原始响应供调试
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 能力模型
|
||||
|
||||
```typescript
|
||||
// ── src/llm/capabilities.ts ─────────────────────────────────
|
||||
|
||||
export interface ProviderCapabilities {
|
||||
/** 是否支持 tool/function calling */
|
||||
supportsTools: boolean;
|
||||
/** 是否支持原生 JSON mode(vs 需要 prompt 指令 + 手动解析) */
|
||||
supportsJsonMode: boolean;
|
||||
/** 是否支持 SSE streaming */
|
||||
supportsStreaming: boolean;
|
||||
/** 是否支持 embedding 接口 */
|
||||
supportsEmbeddings: boolean;
|
||||
/** 是否支持图片/多模态输入 */
|
||||
supportsMultimodal: boolean;
|
||||
/** 最大输入 token 数(用于预校验,避免无效调用) */
|
||||
maxInputTokens?: number;
|
||||
}
|
||||
|
||||
/** 各 provider 默认能力声明 */
|
||||
export const DEFAULT_CAPABILITIES: Record<string, ProviderCapabilities> = {
|
||||
openai_compatible: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: false, // 取决于具体模型
|
||||
},
|
||||
openai_responses: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true,
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
anthropic: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: false, // 无原生 JSON mode,需 prompt 指令
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: false,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
gemini: {
|
||||
supportsTools: true,
|
||||
supportsJsonMode: true, // responseMimeType: 'application/json'
|
||||
supportsStreaming: true,
|
||||
supportsEmbeddings: true,
|
||||
supportsMultimodal: true,
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3.3 Provider 抽象接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/providers/base.ts ───────────────────────────────
|
||||
|
||||
import type { ProviderCapabilities } from '../capabilities';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../types';
|
||||
|
||||
export interface LLMProvider {
|
||||
/** Provider 类型标识 */
|
||||
readonly type: string;
|
||||
|
||||
/** 能力声明 */
|
||||
readonly capabilities: ProviderCapabilities;
|
||||
|
||||
/**
|
||||
* 核心调用方法。Gateway 只调用此方法。
|
||||
* 各 adapter 负责:
|
||||
* 1. 将 LLMChatRequest 转为 provider 原生格式
|
||||
* 2. 发 HTTP / SDK 调用
|
||||
* 3. 将原生响应转为 LLMChatResponse
|
||||
*/
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
|
||||
/** 可选:嵌入接口 */
|
||||
embed?(texts: string[]): Promise<number[][]>;
|
||||
}
|
||||
|
||||
/** Provider 工厂函数签名:从 DB 配置 + 解密后 apiKey 创建实例 */
|
||||
export type ProviderFactory = (config: {
|
||||
baseUrl?: string;
|
||||
apiKey: string;
|
||||
defaultModel: string;
|
||||
extraConfig: Record<string, unknown>;
|
||||
}) => LLMProvider;
|
||||
```
|
||||
|
||||
### 3.4 Gateway 入口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/gateway.ts ──────────────────────────────────────
|
||||
|
||||
import type { ModelRole, LLMChatRequest, LLMChatResponse } from './types';
|
||||
import type { LLMProvider } from './providers/base';
|
||||
|
||||
/**
|
||||
* LLM Gateway — 业务层唯一入口
|
||||
*
|
||||
* 职责:
|
||||
* 1. 根据 role 查询 model_role_assignments → provider_id + model
|
||||
* 2. 从 provider 缓存获取(或按需创建)LLMProvider 实例
|
||||
* 3. 调用 provider.chat() 并返回统一响应
|
||||
* 4. 如果 provider 配置变更(UI 保存时),invalidate 缓存
|
||||
*/
|
||||
export class LLMGateway {
|
||||
/** provider 实例缓存(provider_id → LLMProvider) */
|
||||
private cache = new Map<string, LLMProvider>();
|
||||
|
||||
/**
|
||||
* 按业务角色调用 LLM
|
||||
* @param role 业务角色(planner/specialist/judge/embedding)
|
||||
* @param request 请求(不含 model,由角色映射决定)
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 用指定 provider 直接调用(连通性测试用)
|
||||
*/
|
||||
async chatDirect(
|
||||
providerId: string,
|
||||
request: LLMChatRequest
|
||||
): Promise<LLMChatResponse>;
|
||||
|
||||
/**
|
||||
* 获取指定 provider 的 embedding 接口
|
||||
*/
|
||||
async embedForRole(
|
||||
role: 'embedding',
|
||||
texts: string[]
|
||||
): Promise<number[][]>;
|
||||
|
||||
/** 配置变更时清除单个 provider 缓存 */
|
||||
invalidateProvider(providerId: string): void;
|
||||
|
||||
/** 清除全部缓存(全局配置变更时) */
|
||||
invalidateAll(): void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 四个 Provider Adapter 核心差异映射
|
||||
|
||||
### 4.1 总览对照表
|
||||
|
||||
| 特性 | openai_compatible | openai_responses | anthropic | gemini |
|
||||
|---|---|---|---|---|
|
||||
| **SDK/HTTP** | `openai` npm (`chat.completions`) | `openai` npm (`responses.create`) | `@anthropic-ai/sdk` | `@google/generative-ai` 或 REST |
|
||||
| **系统指令** | `messages[0].role='system'` | `instructions` 参数 | `system` 顶层参数 | `systemInstruction` 参数 |
|
||||
| **JSON mode** | `response_format: {type:'json_object'}` | `text.format: {type:'json_object'}` | 无原生支持 → prompt 指令 + `JSON.parse` | `responseMimeType: 'application/json'` + `responseSchema` |
|
||||
| **工具调用请求** | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].type='function'` + `function.{name,description,parameters}` | `tools[].name` + `input_schema` | `tools[].functionDeclarations[].{name,description,parameters}` |
|
||||
| **工具结果返回** | `role: 'tool'` + `tool_call_id` | `type: 'function_call_output'` + `call_id` | `role: 'user'` + `content: [{type:'tool_result', tool_use_id}]` | `role: 'function'` + `parts: [{functionResponse}]` |
|
||||
| **finish_reason** | `stop` / `tool_calls` / `length` | `stop` / `tool_calls` / ... | `end_turn` / `tool_use` / `max_tokens` | `STOP` / `FUNCTION_CALL` / `MAX_TOKENS` |
|
||||
| **Token 用量** | `usage.{prompt,completion}_tokens` | `usage.{input,output}_tokens` | `usage.{input,output}_tokens` | `usageMetadata.{prompt,candidates}TokenCount` |
|
||||
|
||||
### 4.2 各 Adapter 核心转换逻辑
|
||||
|
||||
#### 4.2.1 openai_compatible(现有兼容格式)
|
||||
|
||||
```typescript
|
||||
// 请求转换:几乎直通(这就是现有代码逻辑的抽象)
|
||||
// - LLMMessage → OpenAI ChatCompletionMessage (直接映射)
|
||||
// - responseFormat='json' → { type: 'json_object' }
|
||||
// - tools → tools[].function (直接映射)
|
||||
//
|
||||
// 响应转换:
|
||||
// - choices[0].message.content → content
|
||||
// - choices[0].message.tool_calls → toolCalls
|
||||
// - choices[0].finish_reason → finishReason (直接映射)
|
||||
// - usage.{prompt,completion}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.2 openai_responses(Responses API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 instructions 参数
|
||||
// - 非 system messages 转为 input items
|
||||
// - responseFormat='json' → text: { format: { type: 'json_object' } }
|
||||
// - tools → tools[].function
|
||||
//
|
||||
// 响应转换:
|
||||
// - output items 中 type='message' → content
|
||||
// - output items 中 type='function_call' → toolCalls
|
||||
// - status → finishReason 映射
|
||||
// - usage.{input,output}_tokens → usage
|
||||
```
|
||||
|
||||
#### 4.2.3 anthropic(Messages API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 system 顶层参数
|
||||
// - 非 system messages → messages(role 直接映射)
|
||||
// - responseFormat='json' → 无原生支持,在 system prompt 末尾追加:
|
||||
// "You MUST respond with valid JSON only. No other text."
|
||||
// - tools → tools[].{ name, description, input_schema }
|
||||
// - tool results → role='user', content: [{ type: 'tool_result', tool_use_id, content }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - content blocks: type='text' → content
|
||||
// - content blocks: type='tool_use' → toolCalls (id, name, JSON.stringify(input))
|
||||
// - stop_reason: 'end_turn' → 'stop', 'tool_use' → 'tool_calls', 'max_tokens' → 'length'
|
||||
// - usage.{input,output}_tokens → usage
|
||||
//
|
||||
// JSON mode 容错:
|
||||
// - JSON.parse(content) 失败时,尝试正则提取 ```json...``` 块
|
||||
```
|
||||
|
||||
#### 4.2.4 gemini(generateContent API)
|
||||
|
||||
```typescript
|
||||
// 请求转换:
|
||||
// - system message 提取为 systemInstruction: { parts: [{ text }] }
|
||||
// - messages → contents[].{ role: 'user'|'model', parts: [{ text }] }
|
||||
// 注意:Gemini 用 'model' 而非 'assistant'
|
||||
// - responseFormat='json' → generationConfig: {
|
||||
// responseMimeType: 'application/json',
|
||||
// responseSchema: <如果有的话>
|
||||
// }
|
||||
// - tools → tools: [{ functionDeclarations: [...] }]
|
||||
// - tool results → role: 'function', parts: [{ functionResponse: { name, response } }]
|
||||
//
|
||||
// 响应转换:
|
||||
// - candidates[0].content.parts: type='text' → content
|
||||
// - candidates[0].content.parts: functionCall → toolCalls
|
||||
// - candidates[0].finishReason: 'STOP' → 'stop', 'FUNCTION_CALL' → 'tool_calls', 'MAX_TOKENS' → 'length'
|
||||
// - usageMetadata.{promptTokenCount, candidatesTokenCount} → usage
|
||||
```
|
||||
|
||||
### 4.3 tool-converter.ts 接口
|
||||
|
||||
```typescript
|
||||
// ── src/llm/tool-converter.ts ───────────────────────────────
|
||||
|
||||
import type { LLMToolDefinition } from './types';
|
||||
|
||||
/**
|
||||
* 将内部通用 LLMToolDefinition 转为各 provider 原生格式。
|
||||
* 由各 adapter 在 chat() 中调用。
|
||||
*/
|
||||
|
||||
/** → OpenAI / OpenAI Compatible 格式 */
|
||||
export function toOpenAITools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Anthropic 格式 */
|
||||
export function toAnthropicTools(tools: LLMToolDefinition[]): object[];
|
||||
|
||||
/** → Gemini functionDeclarations 格式 */
|
||||
export function toGeminiTools(tools: LLMToolDefinition[]): object[];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 后端 REST API 契约
|
||||
|
||||
所有新 API 挂在 `/admin/api/llm/` 下,复用现有 JWT 鉴权中间件。
|
||||
|
||||
### 5.1 Provider 管理
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/providers` | 列出所有 provider(含 `hasKey` 布尔,不含明文 key) |
|
||||
| `POST` | `/admin/api/llm/providers` | 创建 provider + 设置 API Key |
|
||||
| `GET` | `/admin/api/llm/providers/:id` | 获取单个详情 |
|
||||
| `PUT` | `/admin/api/llm/providers/:id` | 更新(name/base_url/default_model/extra_config/is_enabled) |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id` | 删除(级联删 secret + role assignments) |
|
||||
|
||||
### 5.2 API Key(仅 set/clear,不回显)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `PUT` | `/admin/api/llm/providers/:id/key` | 设置/更新 API Key |
|
||||
| `DELETE` | `/admin/api/llm/providers/:id/key` | 清除 API Key |
|
||||
|
||||
### 5.3 角色 → 模型映射
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/llm/roles` | 列出所有角色及当前绑定 |
|
||||
| `PUT` | `/admin/api/llm/roles/:role` | 设置角色绑定 |
|
||||
|
||||
### 5.4 连通性测试
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `POST` | `/admin/api/llm/providers/:id/test` | 发送简单 prompt 验证 provider 可达 |
|
||||
|
||||
### 5.5 通用设置(非 LLM)
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| `GET` | `/admin/api/settings` | 列出所有(sensitive 字段 masked) |
|
||||
| `PUT` | `/admin/api/settings` | 批量更新 |
|
||||
|
||||
### 5.6 请求/响应示例
|
||||
|
||||
#### 创建 Provider
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers
|
||||
// Request:
|
||||
{
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"apiKey": "sk-ant-xxxx",
|
||||
"extraConfig": {}
|
||||
}
|
||||
|
||||
// Response 201:
|
||||
{
|
||||
"id": "a1b2c3d4",
|
||||
"name": "Anthropic Claude",
|
||||
"type": "anthropic",
|
||||
"baseUrl": null,
|
||||
"defaultModel": "claude-sonnet-4-20250514",
|
||||
"isEnabled": true,
|
||||
"hasKey": true,
|
||||
"extraConfig": {},
|
||||
"createdAt": "2026-03-04T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
#### 设置角色绑定
|
||||
|
||||
```jsonc
|
||||
// PUT /admin/api/llm/roles/specialist
|
||||
// Request:
|
||||
{
|
||||
"providerId": "a1b2c3d4",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
|
||||
// Response 200:
|
||||
{
|
||||
"role": "specialist",
|
||||
"providerId": "a1b2c3d4",
|
||||
"providerName": "Anthropic Claude",
|
||||
"providerType": "anthropic",
|
||||
"model": "claude-sonnet-4-20250514"
|
||||
}
|
||||
```
|
||||
|
||||
#### 连通性测试
|
||||
|
||||
```jsonc
|
||||
// POST /admin/api/llm/providers/a1b2c3d4/test
|
||||
// Response 200:
|
||||
{
|
||||
"success": true,
|
||||
"latencyMs": 823,
|
||||
"model": "claude-sonnet-4-20250514",
|
||||
"message": "Hello! I'm Claude, an AI assistant."
|
||||
}
|
||||
|
||||
// Response 200 (失败):
|
||||
{
|
||||
"success": false,
|
||||
"latencyMs": 5012,
|
||||
"error": "401 Unauthorized: Invalid API key"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 密钥安全设计
|
||||
|
||||
### 6.1 Master Key 管理
|
||||
|
||||
```
|
||||
启动流程:
|
||||
1. 读取环境变量 ENCRYPTION_KEY(hex 编码,64 字符)
|
||||
├── 未设置或为空 → 抛出错误,拒绝启动
|
||||
├── 长度不正确 → 抛出错误,提示需要 64 个十六进制字符
|
||||
└── 正确 → 解码为 32 字节 Buffer
|
||||
2. 主密钥常驻内存(进程生命周期)
|
||||
3. 绝对不写入日志、不暴露给 API
|
||||
```
|
||||
|
||||
### 6.2 加密流程(写 API Key)
|
||||
|
||||
```
|
||||
输入: plaintext apiKey (string)
|
||||
1. 生成 12 bytes IV: crypto.randomFillSync(new Uint8Array(12))
|
||||
2. 创建 cipher: crypto.createCipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 加密: ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()])
|
||||
4. 获取 auth tag: authTag = cipher.getAuthTag() // 16 bytes
|
||||
5. 写 DB: INSERT INTO llm_secrets (provider_id, ciphertext, iv, auth_tag, key_version)
|
||||
```
|
||||
|
||||
### 6.3 解密流程(Gateway 需要调 provider)
|
||||
|
||||
```
|
||||
输入: provider_id
|
||||
1. 从 DB 读取: { ciphertext, iv, auth_tag, key_version }
|
||||
2. 创建 decipher: crypto.createDecipheriv('aes-256-gcm', masterKey, iv)
|
||||
3. 设置 auth tag: decipher.setAuthTag(authTag)
|
||||
4. 解密: plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
||||
5. 返回明文 API Key → 传给 provider factory
|
||||
6. 解密后的 key 随 provider 实例缓存在 Gateway.cache 中
|
||||
```
|
||||
|
||||
### 6.4 密钥轮换
|
||||
|
||||
```
|
||||
场景: 管理员更换 ENCRYPTION_KEY
|
||||
1. 启动时读取新的 ENCRYPTION_KEY 环境变量
|
||||
2. 查询所有 llm_secrets WHERE key_version < current_version
|
||||
3. 逐条: 用旧 key 解密 → 用新 key 重加密 → 更新 key_version
|
||||
4. 如果旧 key 不可用(环境变量缺失)→ 启动报错,要求重新设置所有 API Key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端配置页设计
|
||||
|
||||
### 7.1 页面结构
|
||||
|
||||
```
|
||||
Settings 页面
|
||||
├── 🔌 LLM Providers(Tab 或独立 Card)
|
||||
│ │
|
||||
│ ├── Provider 列表表格
|
||||
│ │ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ │ 名称 │ 类型 │ 默认模型 │ Key │ 状态 │ 操作 │
|
||||
│ │ ├───────────────────┼───────────────┼───────────────┼─────┼────────┼──────┤
|
||||
│ │ │ 公司 OpenAI 代理 │ OpenAI Compat │ gpt-4o-mini │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Anthropic Claude │ Anthropic │ claude-sonnet │ ● │ [开关] │ [⋮] │
|
||||
│ │ │ Google Gemini │ Gemini │ gemini-2.5-pro│ ○ │ [开关] │ [⋮] │
|
||||
│ │ └──────────────────────────────────────────────────────────────┘
|
||||
│ │ + 添加 Provider 按钮
|
||||
│ │
|
||||
│ ├── 添加/编辑 Provider 对话框
|
||||
│ │ ├── 名称 (text input)
|
||||
│ │ ├── 类型 (select dropdown)
|
||||
│ │ │ ├── OpenAI Compatible — 兼容 OpenAI 接口的第三方服务
|
||||
│ │ │ ├── OpenAI Responses — OpenAI 官方 Responses API
|
||||
│ │ │ ├── Anthropic — Anthropic Messages API
|
||||
│ │ │ └── Gemini — Google Gemini API
|
||||
│ │ ├── Base URL (text, 条件显示:openai_compatible 必填, 其他可选)
|
||||
│ │ ├── 默认模型 (text + autocomplete suggestions)
|
||||
│ │ ├── API Key (password input, 已有时显示 ••••••••)
|
||||
│ │ ├── ▸ 高级配置 (collapsible, JSON key-value editor)
|
||||
│ │ └── [测试连接] [保存] [取消]
|
||||
│ │
|
||||
│ └── 🧩 角色分配与分级审查映射 区域
|
||||
│ ┌──────────────────────────────────────────────────────────────┐
|
||||
│ │ 角色/阶段 │ Provider 下拉 │ 模型 ID │
|
||||
│ ├──────────────┼──────────────────────┼──────────────────────┤
|
||||
│ │ Planner(Triage) │ [公司 OpenAI 代理 ▾] │ [gpt-4o-mini ] │
|
||||
│ │ Specialist(任务执行) │ [Anthropic Claude ▾] │ [claude-sonnet-4 ] │
|
||||
│ │ Judge(汇总裁决) │ [公司 OpenAI 代理 ▾] │ [gpt-4o ] │
|
||||
│ │ Embedding(记忆检索) │ [公司 OpenAI 代理 ▾] │ [text-embedding-3 ] │
|
||||
│ └──────────────────────────────────────────────────────────────┘
|
||||
│ [保存角色分配]
|
||||
│
|
||||
├── ⚙️ 通用设置(现有 Gitea / 飞书 / App / Review 参数)
|
||||
│ ├── Agent 分级审查参数:small/medium 阈值、token budget、triage 开关
|
||||
│ └── (复用现有 ConfigManager 组件,数据源统一为 DB)
|
||||
```
|
||||
|
||||
### 7.2 交互规则
|
||||
|
||||
| 交互 | 行为 |
|
||||
|---|---|
|
||||
| **添加 Provider** | 弹出对话框;类型选择后动态显示/隐藏 `base_url` 字段 |
|
||||
| **API Key 输入** | 已有 key 时展示 `••••••••`(readonly 占位);清空内容后保存 = 删除 key;输入新值 = 替换(调用 `PUT /key`);未修改 = 不发请求 |
|
||||
| **测试连接** | 点击后调 `POST /providers/:id/test`;显示 spinner → 成功绿色 toast(延迟+模型)/ 失败红色 toast(错误信息) |
|
||||
| **角色分配下拉** | 仅显示 `is_enabled=true` 且 `hasKey=true` 的 provider;选择后自动填充该 provider 的 `default_model`(用户可修改) |
|
||||
| **禁用 Provider** | 如果有角色绑定到此 provider → 弹确认对话框:"此 Provider 正被以下角色使用:[...],禁用后这些角色将无法调用 LLM。确定禁用?" |
|
||||
| **删除 Provider** | 同上,级联影响提示更强烈 |
|
||||
| **模型建议** | 根据 provider type 显示常见模型建议列表(硬编码在前端,仅作参考,不限制输入) |
|
||||
|
||||
### 7.3 模型建议列表(前端硬编码参考)
|
||||
|
||||
```typescript
|
||||
const MODEL_SUGGESTIONS: Record<string, string[]> = {
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'deepseek-chat', 'qwen-plus'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514', 'claude-3-5-haiku-20241022', 'claude-opus-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.0-flash'],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 现有调用点改造清单
|
||||
|
||||
### 8.1 后端代码改造
|
||||
|
||||
| # | 文件 | 当前代码 | 改造为 | 影响范围 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `src/index.ts:69-71` | `const openaiClient = new OpenAI({baseURL, apiKey})` | 删除;初始化 `LLMGateway` 单例并传入业务层 | 入口 |
|
||||
| 2 | `src/controllers/review.ts` | 旧版 webhook 存在回退分支 | 删除回退分支,仅保留 `agent` / `codex` 入队逻辑 | 审查主入口 |
|
||||
| 3 | `src/review/orchestrator.ts:45,61-63` | `private readonly openai: OpenAI` + `this.openai = new OpenAI(...)` | 构造函数改接收 `LLMGateway`;传给各 agent(任务化分级编排:skip/light/full) | Agent 编排 |
|
||||
| 4 | `src/review/agents/specialist-agent.ts:93` | `protected readonly openai: OpenAI` | → `protected readonly gateway: LLMGateway`;`reviewWithOptions()` 与 ReAct 调用改为 `this.gateway.chatForRole('specialist', ...)` | 核心 agent |
|
||||
| 5 | `src/review/agents/critic-agent.ts:23` | `private openai: OpenAI` | 同上 | 评审 agent |
|
||||
| 6 | `src/review/agents/reflexion-agent.ts:24` | `constructor(openai: OpenAI, ...)` | 构造传 gateway | 反思 agent |
|
||||
| 7 | `src/review/agents/debate-orchestrator.ts:17` | `private openai: OpenAI` | 同上 | 辩论 agent |
|
||||
| 8 | `src/review/tools/registry.ts:22` | `toOpenAIFunctions()` | → `toToolDefinitions(): LLMToolDefinition[]`(返回内部格式),由 adapter 的 `tool-converter.ts` 负责转换 | 工具注册 |
|
||||
| 9 | `src/config/config-schema.ts:120-138` | `OPENAI_BASE_URL/API_KEY/MODEL` 字段定义 | 删除这些字段;`group: 'openai'` → 整个 group 移除 | 配置 schema |
|
||||
| 10 | `src/config/config-manager.ts:44-46` | `OPENAI_*` Zod schema 条目 | 删除 | 配置验证 |
|
||||
| 11 | `src/config/config-manager.ts:271-273` | `openai: { baseUrl, apiKey, model }` 映射 | 删除整个 `openai` 块 | 配置输出 |
|
||||
|
||||
### 8.2 前端代码改造
|
||||
|
||||
| # | 文件 | 改造内容 |
|
||||
|---|---|---|
|
||||
| 1 | `frontend/src/services/configService.ts` | 新增 `llmProviderService.ts`(Provider CRUD + Key 管理 + Role 管理 + Test) |
|
||||
| 2 | `frontend/src/components/ConfigManager.tsx` | 添加 "LLM Providers" Tab/Card,引入新组件 |
|
||||
| 3 | 新增 | `frontend/src/components/llm/ProviderList.tsx` — Provider 列表表格 |
|
||||
| 4 | 新增 | `frontend/src/components/llm/ProviderDialog.tsx` — 添加/编辑对话框 |
|
||||
| 5 | 新增 | `frontend/src/components/llm/RoleAssignment.tsx` — 角色分配面板 |
|
||||
|
||||
### 8.3 配置层改造
|
||||
|
||||
| 变更 | 说明 |
|
||||
|---|---|
|
||||
| `config-manager.ts` | 精简为只管非 LLM 配置;数据源统一为 `system_settings` 表 |
|
||||
| `config-schema.ts` | 移除 `openai` group 及其字段;保留 gitea/feishu/app/admin/review(非模型)字段 |
|
||||
| `controllers/config.ts` | LLM 相关接口迁到 `controllers/llm-config.ts`;通用配置接口改读写 DB |
|
||||
| `.env.example` | 移除 `OPENAI_*` 和 `REVIEW_MODEL_*`;仅保留启动参数 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 实施阶段建议
|
||||
|
||||
| 阶段 | 内容 | 依赖 | 估时 |
|
||||
|---|---|---|---|
|
||||
| **Phase 1: 基础设施** | DB 层 (`bun:sqlite` 初始化 + DDL) + crypto 模块 | 无 | 1d |
|
||||
| **Phase 2: LLM 抽象层** | `src/llm/` 全部(types + capabilities + errors + gateway + 4 adapters + tool-converter) | Phase 1 | 2d |
|
||||
| **Phase 3: 后端 API + 调用点替换** | `controllers/llm-config.ts` + 替换 11 个现有 OpenAI 调用点 + 测试 | Phase 2 | 1.5d |
|
||||
| **Phase 4: 前端改造** | Provider 管理 + 角色分配 + 连接测试 UI + 通用设置切 DB | Phase 3 | 1.5d |
|
||||
| **Phase 5: 清理与验收** | 删除旧代码 + 更新文档 + E2E 测试 + `.env.example` 精简 | Phase 4 | 0.5d |
|
||||
|
||||
**总计约 6.5 人天。**
|
||||
|
||||
### 关键里程碑
|
||||
|
||||
```
|
||||
Day 1: DB + crypto 就绪,配置写入链路打通
|
||||
Day 3: LLM Gateway 可用,4 个 adapter 通过单元测试
|
||||
Day 4.5: 后端 API 完成,所有调用点已替换,`bun test` 全绿
|
||||
Day 6: 前端配置页可用,可通过 UI 添加/测试 Provider
|
||||
Day 6.5: 旧代码清理完毕,文档更新,Ready for review
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|---|---|---|
|
||||
| **Anthropic 无原生 JSON mode** | `response_format: json_object` 不可用,JSON 解析可能失败 | Adapter 内 prompt 注入 JSON 指令 + `JSON.parse()` 容错(正则提取 \`\`\`json\`\`\` 块 → 重试 parse) |
|
||||
| **Gemini function calling 格式差异大** | `functionDeclarations` 包装层级不同;`functionResponse` 嵌套在 `parts` 中 | `tool-converter.ts` 单独处理;finish reason 映射表全覆盖测试 |
|
||||
| **Embedding 维度变化导致 Qdrant 不兼容** | `src/review/memory/vector-store.ts` 硬编码 1536 维 | `model_role_assignments.role='embedding'` 变更时,UI 提示用户需重建 collection;或自动检测维度创建新 collection |
|
||||
| **ENCRYPTION_KEY 丢失** | 所有加密的 API Key 不可恢复 | 启动时检测密钥版本不匹配 → 报错并要求重新设置所有 API Key(trade-off:安全性 > 便利性) |
|
||||
| **SQLite 并发写** | 多请求同时写入可能 SQLITE_BUSY | `bun:sqlite` 开启 WAL mode;写操作走单连接序列化;读可并行 |
|
||||
| **Provider SDK 版本冲突** | `openai`、`@anthropic-ai/sdk`、`@google/generative-ai` 三个 SDK 共存 | 各 adapter 独立 import,无交叉依赖;`package.json` 锁定主版本 |
|
||||
| **配置热更新** | UI 修改 provider 配置后,正在进行的审查仍用旧配置 | Gateway 缓存按 provider_id 粒度 invalidate;正在执行的请求不受影响(用的是已创建的实例),下次请求用新实例 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A: 新增依赖
|
||||
|
||||
```jsonc
|
||||
// package.json 新增
|
||||
{
|
||||
"dependencies": {
|
||||
// bun:sqlite 是 Bun 内置,无需安装
|
||||
"@anthropic-ai/sdk": "^0.39.0", // Anthropic adapter
|
||||
"@google/generative-ai": "^0.24.0" // Gemini adapter
|
||||
// "openai" 已存在: "^4.87.3" // OpenAI compatible + Responses adapter
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录 B: 环境变量精简
|
||||
|
||||
```bash
|
||||
# .env.example(仅保留启动参数)
|
||||
|
||||
# 应用启动参数(不可通过 UI 设置)
|
||||
PORT=5174
|
||||
WEBHOOK_SECRET=your_webhook_secret
|
||||
DATABASE_PATH=./data/assistant.db # SQLite 文件路径
|
||||
|
||||
# 以下配置已迁入数据库,通过 Web UI 管理:
|
||||
# - LLM Provider 配置(API Key / Base URL / Model)
|
||||
# - Gitea 配置(API URL / Token)
|
||||
# - 飞书配置(Webhook URL / Secret)
|
||||
# - Review 引擎配置
|
||||
# - 记忆系统配置
|
||||
```
|
||||
@@ -1,154 +0,0 @@
|
||||
# UI Theme Language(亮/暗双主题统一规范)
|
||||
|
||||
## 目标
|
||||
|
||||
- 保证浅色/深色主题视觉一致、可读性稳定。
|
||||
- 避免组件直接写死颜色,防止后续开发样式漂移。
|
||||
- 让新增页面默认遵循同一套语义化设计语言。
|
||||
|
||||
## 三层设计语言模型
|
||||
|
||||
1. **Primitive(原子值)**:HSL 基础值,仅在全局 token 定义处出现。
|
||||
2. **Semantic(语义 token)**:`background`、`foreground`、`success`、`danger` 等,按语义命名。
|
||||
3. **Component(组件 token)**:组件只能消费语义 token,不允许跨层引用原子值。
|
||||
|
||||
## 当前项目的主题基线
|
||||
|
||||
主题定义文件:`frontend/src/index.css`
|
||||
|
||||
- 基础语义:`background`、`foreground`、`card`、`muted`、`border`、`ring`
|
||||
- 状态语义:`success`、`warning`、`danger`、`info`
|
||||
- 补充语义:`surface-muted`、`surface-elevated`、`surface-overlay`、`text-subtle`、`text-soft`、`border-soft`
|
||||
|
||||
Tailwind 语义映射:`frontend/tailwind.config.js`
|
||||
|
||||
- 已将语义 token 映射为可直接使用的 class(如 `bg-success/10`、`text-danger`、`border-info/20`)。
|
||||
|
||||
### 主色方案(当前)
|
||||
|
||||
- 主色选择:**Cobalt Blue(钴蓝)**,兼顾 light/dark 的对比度与品牌辨识度。
|
||||
- Light:`--primary: 224 76% 52%`,`--primary-foreground: 0 0% 100%`
|
||||
- Dark:`--primary: 224 88% 68%`,`--primary-foreground: 224 40% 12%`
|
||||
- 焦点环:`--ring` 与 `--primary` 保持同色,确保交互一致性。
|
||||
- 设计理由:亮色下避免过“脏”或偏绿感;暗色下提升明度保证可见性,同时用深色前景保证主按钮文字对比。
|
||||
|
||||
## 可选整套主题色方案(社区开源复用)
|
||||
|
||||
> 要求:切换的是**整套语义 token**,不是只改 `primary`。
|
||||
|
||||
当前支持四套(统一冷调科技风):
|
||||
|
||||
1. `cobalt`(默认)
|
||||
- 本项目当前默认冷色科技风(自定义)。
|
||||
2. `zinc`
|
||||
- 来源:shadcn/ui themes(MIT)
|
||||
- 参考:<https://github.com/shadcn-ui/ui/blob/main/apps/v4/public/r/themes.css>
|
||||
3. `nord`
|
||||
- 来源:Nord(MIT)
|
||||
- 参考:<https://github.com/nordtheme/nord>
|
||||
4. `tokyo-night`
|
||||
- 来源:Tokyo Night(Apache-2.0)
|
||||
- 参考:<https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
实现方式:
|
||||
|
||||
- `cobalt` 作为内置基础主题,直接由 `:root` / `.dark` 提供默认 token。
|
||||
- 其余方案(`zinc|nord|tokyo-night`)通过 `data-palette` 覆盖:
|
||||
- `:root[data-palette='*']` 覆盖浅色 token
|
||||
- `.dark[data-palette='*']` 覆盖暗色 token
|
||||
- 在根节点写入 `data-palette`(`cobalt|zinc|nord|tokyo-night`)。
|
||||
- 组件侧不改业务 class,继续消费语义 token。
|
||||
|
||||
## 页面风格骨架(社区方案落地)
|
||||
|
||||
> 目标:即使切换配色,页面结构、密度、层级、动效仍保持统一。
|
||||
|
||||
本项目采用了三类社区成熟范式,并映射到本仓库 utility:
|
||||
|
||||
1. **4px 节奏与密度系统(shadcn/仪表盘实践)**
|
||||
- 基础节奏按 4px 递进,主内容区使用 `theme-page-content`(统一宽度 + 留白节奏)。
|
||||
- 卡片内部与卡片间距默认采用 `p-6 / gap-6` 级别,避免页面“块状松散或拥挤”。
|
||||
2. **三层深度系统(卡片/悬浮/遮罩)**
|
||||
- 统一卡片外观:`theme-card-shell` + `theme-card-header` + `theme-card-content`。
|
||||
- 交互抬升统一:`theme-interactive-elevate`(轻微位移 + 阴影,不做夸张动效)。
|
||||
- 页面壳层统一:`theme-shell-gradient` + `theme-sticky-bar`。
|
||||
3. **可控动效系统(Linear/Vercel 风格)**
|
||||
- Hover/按钮反馈优先短时平滑动效,避免大幅动画导致“廉价感”。
|
||||
- 表单输入统一 `theme-input-surface`,状态条与统计胶囊统一 `theme-control-pill`。
|
||||
|
||||
参考来源:
|
||||
|
||||
- shadcn/ui themes 与组件风格实践(MIT):<https://github.com/shadcn-ui/ui>
|
||||
- Vercel Dashboard 设计迭代思路:<https://vercel.com/changelog/dashboard-navigation-redesign-rollout>
|
||||
- Nord / Tokyo Night 社区配色体系:
|
||||
- <https://github.com/nordtheme/nord>
|
||||
- <https://github.com/folke/tokyonight.nvim>
|
||||
|
||||
## 强制规则(必须遵守)
|
||||
|
||||
1. **禁止在业务 TSX 中使用硬编码暗色类**:如 `bg-zinc-*`、`text-zinc-*`、`border-white/10`(历史 UI 基础组件逐步迁移,不作为新增业务代码例外)。
|
||||
2. **禁止在组件内写死颜色值**:如 `rgba(...)`、`#xxxxxx`、`rgb(...)`。
|
||||
3. **状态色统一语义化**:成功/警告/错误/信息统一用 `success|warning|danger|info`。
|
||||
4. **弹窗/卡片/表格优先使用语义表面色**:`card`、`muted`、`popover`、`background`。
|
||||
5. **交互阴影统一工具类**:`theme-glow-primary|success|warning|danger`。
|
||||
6. **普通 hover 反馈禁止用主色背景**:非主操作控件统一使用 `hover:bg-accent*` 或 `hover:bg-muted*`,避免亮色主题出现重色块。
|
||||
|
||||
## 推荐 class 使用方式
|
||||
|
||||
- 文字层级:`text-foreground` / `text-muted-foreground`
|
||||
- 面板层级:`bg-card` / `bg-muted/50` / `bg-popover`
|
||||
- 边框层级:`border-border` / `theme-border-soft`
|
||||
- 状态展示:`text-success`、`bg-danger/10`、`border-warning/30`
|
||||
- 普通交互 hover:`hover:bg-accent/60`、`hover:bg-accent`、`hover:bg-muted/60`
|
||||
- 主操作 hover:仅主按钮可用 `hover:bg-primary/90`
|
||||
- 顶部吸附操作栏:`theme-sticky-bar`
|
||||
- 页面骨架:`theme-page-frame` / `theme-page-actions` / `theme-page-content`
|
||||
- 卡片骨架:`theme-card-shell` / `theme-card-header` / `theme-card-content`
|
||||
- 弹窗骨架:`theme-dialog-panel` / `theme-dialog-header` / `theme-dialog-body` / `theme-dialog-footer`
|
||||
- 错误态容器:`theme-error-panel`
|
||||
- 模态遮罩:`theme-surface-overlay`
|
||||
|
||||
## 页面级统一约束(防止布局风格漂移)
|
||||
|
||||
1. 页面容器优先使用 `theme-page-frame`,避免每个页面自行定义高度和底部间距。
|
||||
2. 顶部操作区统一使用 `theme-sticky-bar + theme-page-actions`,避免按钮栏视觉断层。
|
||||
3. 主内容区统一使用 `theme-page-content`,确保横向节奏和留白一致。
|
||||
4. 标准业务卡片统一使用 `theme-card-shell/header/content`,避免同类卡片出现不同边框/背景层级。
|
||||
5. 错误提示统一使用 `theme-error-panel`,保持状态反馈视觉语言一致。
|
||||
|
||||
## destructive 与 danger 的约定
|
||||
|
||||
- `destructive`:保留给 shadcn 组件内置 destructive 变体语义。
|
||||
- `danger`:业务状态语义(报错、失败、风险提示)统一使用。
|
||||
- 新业务组件优先使用 `danger`,避免 `destructive/danger` 混用造成漂移。
|
||||
|
||||
## 新功能开发检查清单
|
||||
|
||||
- [ ] 页面在 light/dark 下均可读(文本、边框、状态色有对比度)
|
||||
- [ ] 无 `zinc/white` 等暗色硬编码 class
|
||||
- [ ] 无内联 `style` 颜色值
|
||||
- [ ] 状态色全部使用语义 token
|
||||
- [ ] 组件未绕过语义层直接访问原子颜色
|
||||
- [ ] `bun run ui:visual` 通过(light/dark 关键页面视觉回归)
|
||||
|
||||
## 视觉基线截图回归(Playwright)
|
||||
|
||||
- 生成/更新基线:`bun run ui:visual:update`
|
||||
- 校验基线一致性:`bun run ui:visual`
|
||||
- 轻量 UI 全链路:`bun run ui:regression && bun run ui:visual`
|
||||
|
||||
约定:
|
||||
|
||||
1. PR 默认运行 `ui:visual`,出现 diff 必须人工确认是“预期视觉变更”。
|
||||
2. 只有在确认设计变更成立时,才执行 `ui:visual:update` 更新基线并提交快照。
|
||||
3. 不允许在未更新设计规范的情况下大量更新视觉基线,避免把漂移“固化为正确”。
|
||||
4. 基线快照以 Linux CI 环境为准(当前为 `*-linux.png`),避免跨系统更新导致快照噪声。
|
||||
|
||||
## 迁移策略
|
||||
|
||||
当新增模块时,按以下顺序处理:
|
||||
|
||||
1. 先补充语义 token(如确有新语义,而不是新颜色)。
|
||||
2. 在 Tailwind 映射语义 token。
|
||||
3. 在组件中只消费语义 class。
|
||||
4. 最后做 light/dark 视觉回归。
|
||||
@@ -2,68 +2,92 @@
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- [Bun](https://bun.sh) >= 1.2.5
|
||||
- A reachable Gitea instance
|
||||
- At least one LLM provider credential
|
||||
- At least one LLM provider credential (OpenAI, Anthropic, Gemini, or compatible)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
git clone https://github.com/jeffusion/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
`bun install` at repository root installs frontend dependencies via `postinstall`.
|
||||
|
||||
If lifecycle scripts are disabled:
|
||||
`bun install` at repository root installs frontend dependencies via `postinstall`. If lifecycle scripts are disabled:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## Minimal environment
|
||||
## Configure environment
|
||||
|
||||
Create `.env`:
|
||||
Create `.env` in the project root:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # required, generate with: openssl rand -hex 32
|
||||
ENCRYPTION_KEY=<generate with: openssl rand -hex 32>
|
||||
# PORT=5174
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # local dev default; use LOG_LEVEL=error in production
|
||||
# LOG_LEVEL=info
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` is required. Application startup fails when it is missing.
|
||||
`ENCRYPTION_KEY` is required — the application refuses to start without it. It is the AES-256-GCM master key for encrypting API keys stored in the database.
|
||||
|
||||
See [Configuration](./configuration.md) for all environment variables and runtime settings.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# or
|
||||
bun run start
|
||||
bun run dev # development with hot reload
|
||||
bun run start # production mode
|
||||
```
|
||||
|
||||
Open `http://localhost:5174` to access the Admin UI.
|
||||
|
||||
## First login
|
||||
|
||||
- Open `http://your-server:5174`
|
||||
- Default admin password is `password` on first boot
|
||||
- Change admin password immediately after login
|
||||
- Default admin password is `password` on first boot.
|
||||
- **Change it immediately** after login (Security section in Admin UI).
|
||||
|
||||
## Configure in Admin UI
|
||||
|
||||
The Admin UI manages all runtime settings stored in SQLite. You only need `.env` for infrastructure bootstrap values.
|
||||
|
||||

|
||||
|
||||
Key settings to configure:
|
||||
|
||||
1. **Gitea** — API URL, access token
|
||||
2. **LLM Providers** — add at least one provider (OpenAI Compatible, Anthropic, Gemini, etc.) with API key and default model
|
||||
3. **Webhook Secret** — used for HMAC-SHA256 signature verification
|
||||
|
||||
See [Screenshots](./screenshots.md) for a full UI gallery.
|
||||
|
||||
## Webhook setup
|
||||
|
||||
### Option A: Admin UI (recommended)
|
||||
|
||||
In repository list, click enable to auto-provision webhook.
|
||||
In the repository list page, click the enable button. The system auto-provisions the webhook in Gitea.
|
||||
|
||||
### Option B: Manual
|
||||
|
||||
In Gitea repository settings:
|
||||
In Gitea repository settings → Webhooks → Add webhook:
|
||||
|
||||
- URL: `http://your-server:5174/webhook/gitea`
|
||||
- Content Type: `application/json`
|
||||
- Secret: same value as dashboard webhook secret
|
||||
- Events: Pull Request + Status
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| URL | `http://your-server:5174/webhook/gitea` |
|
||||
| Content Type | `application/json` |
|
||||
| Secret | Same value as configured in Admin UI |
|
||||
| Events | Pull Request + Status |
|
||||
|
||||
## Health endpoint
|
||||
## Health check
|
||||
|
||||
Use `/api/health` to check service status.
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
## Next steps
|
||||
|
||||
- [Configuration reference](./configuration.md) — all settings and runtime model
|
||||
- [Review engines](./review-engines.md) — Agent engine, Codex engine, review modes
|
||||
- [Deployment](./deployment.md) — Docker, Compose, Kubernetes
|
||||
|
||||
@@ -2,68 +2,92 @@
|
||||
|
||||
## 环境要求
|
||||
|
||||
- Bun >= 1.2.5
|
||||
- [Bun](https://bun.sh) >= 1.2.5
|
||||
- 可访问的 Gitea 实例
|
||||
- 至少一个 LLM 提供商凭证
|
||||
- 至少一个 LLM 提供商凭证(OpenAI、Anthropic、Gemini 或兼容接口)
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
git clone https://github.com/user/gitea-ai-assistant.git
|
||||
git clone https://github.com/jeffusion/gitea-ai-assistant.git
|
||||
cd gitea-ai-assistant
|
||||
bun install
|
||||
```
|
||||
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装 `frontend` 依赖。
|
||||
|
||||
如果你的环境禁用了生命周期脚本:
|
||||
在仓库根目录执行 `bun install` 会通过 `postinstall` 自动安装前端依赖。如果环境禁用了生命周期脚本:
|
||||
|
||||
```bash
|
||||
bun run bootstrap
|
||||
```
|
||||
|
||||
## 最小环境变量
|
||||
## 配置环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
在项目根目录创建 `.env` 文件:
|
||||
|
||||
```bash
|
||||
PORT=5174
|
||||
ENCRYPTION_KEY= # 必填,使用 openssl rand -hex 32 生成
|
||||
ENCRYPTION_KEY=<使用 openssl rand -hex 32 生成>
|
||||
# PORT=5174
|
||||
# DATABASE_PATH=./data/assistant.db
|
||||
# LOG_LEVEL=info # 本地开发默认;生产环境请使用 LOG_LEVEL=error
|
||||
# LOG_LEVEL=info
|
||||
```
|
||||
|
||||
> `ENCRYPTION_KEY` 为必填项,缺失时服务会拒绝启动。
|
||||
`ENCRYPTION_KEY` 为必填项——缺失时服务会拒绝启动。它是用于加密数据库中 API Key 的 AES-256-GCM 主密钥。
|
||||
|
||||
所有环境变量和运行时设置参见 [配置参考](./configuration.zh-CN.md)。
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
bun run dev
|
||||
# 或
|
||||
bun run start
|
||||
bun run dev # 开发模式(热重载)
|
||||
bun run start # 生产模式
|
||||
```
|
||||
|
||||
访问 `http://localhost:5174` 进入管理后台。
|
||||
|
||||
## 首次登录
|
||||
|
||||
- 访问 `http://your-server:5174`
|
||||
- 首次启动默认管理员密码为 `password`
|
||||
- 登录后请立即修改管理员密码
|
||||
- **登录后请立即修改密码**(管理后台「安全」分区)
|
||||
|
||||
## 管理后台配置
|
||||
|
||||
管理后台管理所有持久化到 SQLite 的运行时配置。`.env` 仅用于基础设施级引导参数。
|
||||
|
||||

|
||||
|
||||
需要配置的关键项:
|
||||
|
||||
1. **Gitea** — API URL、Access Token
|
||||
2. **LLM Provider** — 添加至少一个提供商(OpenAI Compatible、Anthropic、Gemini 等)并配置 API Key 和默认模型
|
||||
3. **Webhook Secret** — 用于 HMAC-SHA256 签名验证
|
||||
|
||||
完整界面截图参见 [截图集](./screenshots.zh-CN.md)。
|
||||
|
||||
## Webhook 配置
|
||||
|
||||
### 方式 A:管理后台(推荐)
|
||||
|
||||
在仓库列表点击启用按钮,由系统自动配置 webhook。
|
||||
在仓库列表页点击启用按钮,系统自动在 Gitea 中配置 Webhook。
|
||||
|
||||
### 方式 B:手动配置
|
||||
|
||||
在 Gitea 仓库设置中配置:
|
||||
在 Gitea 仓库设置 → Webhooks → 添加 Webhook:
|
||||
|
||||
- URL:`http://your-server:5174/webhook/gitea`
|
||||
- Content Type:`application/json`
|
||||
- Secret:与管理后台中的 Webhook Secret 保持一致
|
||||
- 事件:Pull Request + Status
|
||||
| 字段 | 值 |
|
||||
|---|---|
|
||||
| URL | `http://your-server:5174/webhook/gitea` |
|
||||
| Content Type | `application/json` |
|
||||
| Secret | 与管理后台中配置的 Webhook Secret 保持一致 |
|
||||
| 事件 | Pull Request + Status |
|
||||
|
||||
## 健康检查
|
||||
|
||||
可通过 `/api/health` 查看服务状态。
|
||||
```
|
||||
GET /api/health
|
||||
```
|
||||
|
||||
## 下一步
|
||||
|
||||
- [配置参考](./configuration.zh-CN.md) — 完整设置项与运行时模型
|
||||
- [审查引擎](./review-engines.zh-CN.md) — Agent 引擎、Codex 引擎与审查模式
|
||||
- [部署指南](./deployment.zh-CN.md) — Docker、Compose、Kubernetes
|
||||
|
||||
@@ -1,37 +1,81 @@
|
||||
# Review Engines
|
||||
|
||||
## Overview
|
||||
|
||||
The system supports two engines:
|
||||
|
||||
- `agent`: native staged review pipeline
|
||||
- `codex`: Codex CLI-backed review pipeline
|
||||
|
||||
Engine is selected by `REVIEW_ENGINE` runtime configuration.
|
||||
The system supports two review engines, selected by `REVIEW_ENGINE` in Admin UI.
|
||||
|
||||
## Agent engine
|
||||
|
||||
Agent engine classifies changes and dispatches specialist tasks.
|
||||
The Agent engine uses a dynamic agent framework. It prepares the workspace and review context, then starts a main agent to perform the review.
|
||||
|
||||
### How it works
|
||||
|
||||
1. **Main Agent** — the entrypoint agent that coordinates the review. It uses available tools to analyze code changes.
|
||||
2. **Dynamic Subagents** — the main agent can autonomously spawn subagents for focused tasks (e.g. searching code, reading files). Subagents are created at runtime through tool calls, not hardcoded in the workflow.
|
||||
3. **Deterministic Publishing** — findings and comments are collected and processed outside the agent loop. The system normalizes, deduplicates, and filters findings deterministically before posting to Gitea.
|
||||
|
||||
### Review modes
|
||||
|
||||
- `skip`: low-risk changes may bypass specialist review
|
||||
- `light`: minimal specialist checks for low-risk code changes
|
||||
- `full`: full specialist review for risky or larger changes
|
||||
| Mode | Behavior |
|
||||
|---|---|
|
||||
| `skip` | Low-risk changes bypass review entirely |
|
||||
| `light` | Minimal checks for low-risk code changes |
|
||||
| `full` | Complete review for risky or large changes |
|
||||
|
||||
### Size policy
|
||||
|
||||
`small`/`medium`/`large` thresholds are used by triage to choose mode and token budgets.
|
||||
Change size determines execution mode and token budgets:
|
||||
|
||||
| Size | Typical threshold |
|
||||
|---|---|
|
||||
| `small` | Few lines changed |
|
||||
| `medium` | Moderate change set |
|
||||
| `large` | Significant refactoring or many files |
|
||||
|
||||
> Size and mode are separate layers: `small/medium/large` classifies how big the change is; `skip/light/full` controls how deeply the engine reviews it.
|
||||
|
||||
## Codex engine
|
||||
|
||||
Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
The Codex engine runs review through Codex CLI with independent runtime settings:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
| Setting | Description |
|
||||
|---|---|
|
||||
| `CODEX_API_URL` | Codex API endpoint |
|
||||
| `CODEX_API_KEY` | Codex API key |
|
||||
| `CODEX_MODEL` | Model to use |
|
||||
| `CODEX_TIMEOUT_MS` | Request timeout |
|
||||
| `CODEX_REVIEW_PROMPT` | Custom review prompt |
|
||||
|
||||
## Agent definitions
|
||||
|
||||
Agent definitions are Markdown files with YAML frontmatter stored in the reviewed repository:
|
||||
|
||||
```
|
||||
.gitea-assistant/agents/*.md
|
||||
```
|
||||
|
||||
Each file defines:
|
||||
|
||||
- **System prompt** — instructions for the agent
|
||||
- **Model** — which LLM model to use (optional; falls back to runtime defaults)
|
||||
- **Max turns** — limit for the agent loop
|
||||
- **Tools** — which tools the agent can access
|
||||
|
||||
### Model resolution
|
||||
|
||||
When the main agent spawns a subagent, the model is resolved in this order:
|
||||
|
||||
1. `spawn` override (explicit in the tool call)
|
||||
2. `AgentDefinition.model` (declared in the agent definition file)
|
||||
3. `AGENT_DEFAULT_SUBAGENT_MODEL` (runtime config)
|
||||
4. `AGENT_MAIN_MODEL` (runtime config)
|
||||
|
||||
## Tool permissions
|
||||
|
||||
Tool permissions are controlled within each agent's definition file:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `tools` | Allow list | Tool names the agent is permitted to call. Empty list grants no tools. |
|
||||
| `disallowedTools` | Deny list | Tool names the agent is explicitly forbidden from calling. Takes precedence over `tools`. |
|
||||
|
||||
## Event support
|
||||
|
||||
@@ -42,5 +86,5 @@ Both engines process:
|
||||
|
||||
## Output
|
||||
|
||||
- PR/commit summary comment
|
||||
- Line-level findings with confidence and severity
|
||||
- PR/commit summary comment (posted as an issue comment)
|
||||
- Line-level findings with confidence and severity (posted as review comments)
|
||||
|
||||
@@ -1,37 +1,81 @@
|
||||
# 审查引擎
|
||||
|
||||
## 概览
|
||||
|
||||
系统支持两种审查引擎:
|
||||
|
||||
- `agent`:原生任务化分级审查
|
||||
- `codex`:基于 Codex CLI 的审查流水线
|
||||
|
||||
通过运行时配置 `REVIEW_ENGINE` 选择引擎。
|
||||
系统支持两种审查引擎,通过管理后台的 `REVIEW_ENGINE` 配置选择。
|
||||
|
||||
## Agent 引擎
|
||||
|
||||
Agent 引擎会先做变更分流,再按领域派发 specialist 任务。
|
||||
Agent 引擎使用动态 Agent 框架执行代码审查。它会准备工作区与审查上下文,然后启动主 Agent 执行审查任务。
|
||||
|
||||
### 工作原理
|
||||
|
||||
1. **主 Agent** — 协调审查流程的入口 Agent,使用可用工具分析代码变更。
|
||||
2. **动态子 Agent** — 主 Agent 可在运行时自主生成子 Agent,执行聚焦任务(如搜索代码、读取文件)。子 Agent 通过工具调用动态创建,而非硬编码在工作流中。
|
||||
3. **确定性发布** — 审查发现与评论在 Agent 循环之外收集和处理。系统在发布到 Gitea 之前,对发现进行确定性的规范化、去重和过滤。
|
||||
|
||||
### 审查模式
|
||||
|
||||
- `skip`:低风险改动可跳过 specialist
|
||||
- `light`:对低风险代码执行最小化专项检查
|
||||
- `full`:对高风险或大规模改动执行完整审查
|
||||
| 模式 | 行为 |
|
||||
|---|---|
|
||||
| `skip` | 低风险改动完全跳过审查 |
|
||||
| `light` | 对低风险代码执行最小化检查 |
|
||||
| `full` | 对高风险或大规模改动执行完整审查 |
|
||||
|
||||
### 规模策略
|
||||
|
||||
`small` / `medium` / `large` 阈值用于 triage 阶段决策模式与 token 预算。
|
||||
变更规模决定执行模式与 Token 预算:
|
||||
|
||||
| 规模 | 典型阈值 |
|
||||
|---|---|
|
||||
| `small` | 少量行变更 |
|
||||
| `medium` | 中等变更集 |
|
||||
| `large` | 大规模重构或多文件变更 |
|
||||
|
||||
> 规模与模式是两个层次:`small/medium/large` 分类变更的大小;`skip/light/full` 控制审查的深度。
|
||||
|
||||
## Codex 引擎
|
||||
|
||||
Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
- `CODEX_API_URL`
|
||||
- `CODEX_API_KEY`
|
||||
- `CODEX_MODEL`
|
||||
- `CODEX_TIMEOUT_MS`
|
||||
- `CODEX_REVIEW_PROMPT`
|
||||
| 设置项 | 说明 |
|
||||
|---|---|
|
||||
| `CODEX_API_URL` | Codex API 端点 |
|
||||
| `CODEX_API_KEY` | Codex API 密钥 |
|
||||
| `CODEX_MODEL` | 使用的模型 |
|
||||
| `CODEX_TIMEOUT_MS` | 请求超时时间 |
|
||||
| `CODEX_REVIEW_PROMPT` | 自定义审查提示词 |
|
||||
|
||||
## Agent 定义
|
||||
|
||||
Agent 定义以带 YAML Frontmatter 的 Markdown 文件形式存储在被审查的仓库中:
|
||||
|
||||
```
|
||||
.gitea-assistant/agents/*.md
|
||||
```
|
||||
|
||||
每个文件定义:
|
||||
|
||||
- **系统提示词** — Agent 的指令
|
||||
- **模型** — 使用的 LLM 模型(可选;未指定时使用运行时默认值)
|
||||
- **最大轮数** — Agent 循环上限
|
||||
- **工具** — Agent 可使用的工具
|
||||
|
||||
### 模型解析
|
||||
|
||||
主 Agent 生成子 Agent 时,模型按以下顺序解析:
|
||||
|
||||
1. `spawn` 覆盖(工具调用中显式指定)
|
||||
2. `AgentDefinition.model`(Agent 定义文件中声明)
|
||||
3. `AGENT_DEFAULT_SUBAGENT_MODEL`(运行时配置)
|
||||
4. `AGENT_MAIN_MODEL`(运行时配置)
|
||||
|
||||
## 工具权限
|
||||
|
||||
工具权限在每个 Agent 的定义文件中控制:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|---|---|---|
|
||||
| `tools` | 白名单 | 允许该 Agent 调用的工具名称。空列表表示不授予任何工具权限 |
|
||||
| `disallowedTools` | 黑名单 | 显式禁止该 Agent 调用的工具名称。优先级高于白名单 |
|
||||
|
||||
## 事件支持
|
||||
|
||||
@@ -42,5 +86,5 @@ Codex 引擎通过 Codex CLI 执行审查,支持独立配置:
|
||||
|
||||
## 输出
|
||||
|
||||
- PR/提交总结评论
|
||||
- 行级问题(含置信度与严重性)
|
||||
- PR/提交总结评论(作为 Issue 评论发布)
|
||||
- 行级问题(含置信度与严重性,作为审查评论发布)
|
||||
|
||||
@@ -11,8 +11,6 @@ RUN bun install --no-frozen-lockfile
|
||||
COPY src ./src
|
||||
COPY tsconfig.json .
|
||||
|
||||
COPY frontend/dist ./public
|
||||
|
||||
EXPOSE 5174
|
||||
|
||||
CMD ["bun", "run", "start"]
|
||||
|
||||
258
e2e/README.md
Normal file
258
e2e/README.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# E2E 真实 PR 审查测试指南
|
||||
|
||||
本指南记录使用 Docker 运行 Gitea + Assistant 进行真实 PR 代码审查的完整流程,包括踩坑点和修复步骤。
|
||||
|
||||
## 前置条件
|
||||
|
||||
- Docker & Docker Compose
|
||||
- `openssl`(签名计算)
|
||||
- `python3`(JSON 解析)
|
||||
- 本项目源码
|
||||
|
||||
## 架构概览
|
||||
|
||||
```
|
||||
Gitea (port 3333) ←→ Assistant (port 3334)
|
||||
↑ ↑
|
||||
webhook clone + comment
|
||||
(PR 事件) (git + API)
|
||||
```
|
||||
|
||||
- **Gitea**:代码托管,运行在 `gitea:3000`(宿主机 `localhost:3333`)
|
||||
- **Assistant**:AI 审查服务,运行在 `assistant:5174`(宿主机 `localhost:3334`)
|
||||
- 两者通过 Docker 内部网络 `gitea:3000` 通信(非宿主机地址)
|
||||
|
||||
## 一键启动(自动化方式)
|
||||
|
||||
```bash
|
||||
# 1. 启动容器
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
|
||||
# 2. 等待 Gitea healthy 后创建用户(Gitea 不允许 root 执行 admin 命令)
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
|
||||
# 3. 运行 seed 脚本(创建仓库、推送代码、配置 webhook、创建 PR)
|
||||
bash ./e2e/seed.sh
|
||||
|
||||
# 4. 用 seed 输出的 token 重启 assistant(使 GITEA_ACCESS_TOKEN 生效)
|
||||
# seed.sh 会在最后打印实际 token 值
|
||||
E2E_GITEA_TOKEN=<seed输出的token> docker compose -f docker-compose.e2e.yml up -d assistant
|
||||
|
||||
# 5. 通过 Admin API 更新运行时配置
|
||||
LOGIN_RESP=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}')
|
||||
JWT=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<seed输出的token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
|
||||
# 6. 运行 E2E 测试
|
||||
bash ./e2e/test.sh
|
||||
```
|
||||
|
||||
## 分步详解与踩坑点
|
||||
|
||||
### 1. 启动容器
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml up -d
|
||||
```
|
||||
|
||||
**踩坑**:`ENCRYPTION_KEY` 和 `WEBHOOK_SECRET` 必须在 `docker-compose.e2e.yml` 中配置,否则 assistant 启动失败(`ENCRYPTION_KEY is required`)。已添加默认值:
|
||||
|
||||
```yaml
|
||||
assistant:
|
||||
environment:
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef...(64位hex)}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
```
|
||||
|
||||
### 2. 创建 Gitea 用户
|
||||
|
||||
```bash
|
||||
docker exec e2e-gitea su git -c \
|
||||
"gitea admin user create --username e2e-admin --password 'e2ePassword123!' \
|
||||
--email admin@e2e-test.local --admin --must-change-password=false"
|
||||
```
|
||||
|
||||
**踩坑**:`seed.sh` 中直接 `docker exec e2e-gitea gitea admin user create ...` 会报错 `Gitea is not supposed to be run as root`。必须用 `su git -c` 切换到 git 用户执行。如果用户已存在会输出错误但可忽略。
|
||||
|
||||
### 3. Seed 初始化
|
||||
|
||||
```bash
|
||||
bash ./e2e/seed.sh
|
||||
```
|
||||
|
||||
Seed 脚本会执行:
|
||||
1. 等待 Gitea 就绪
|
||||
2. 创建管理员用户(如已存在则跳过)
|
||||
3. 生成 API Token
|
||||
4. 创建测试仓库并推送含已知 bug 的代码(`src/user-handler.ts` 包含 eval/SQL 注入/硬编码密钥)
|
||||
5. 配置 Assistant 设置(需要 assistant 已启动)
|
||||
6. 配置 Gitea Webhook(指向 `http://assistant:5174/webhook/gitea`)
|
||||
7. 创建 PR #1(`feature/add-user-handler` → `main`)
|
||||
|
||||
**踩坑**:seed.sh 第 5 步"配置 Assistant 设置"可能失败(assistant 未启动或 JWT 获取失败),这不影响后续流程——可以手动通过 API 配置。
|
||||
|
||||
### 4. 更新 Assistant 运行时配置
|
||||
|
||||
```bash
|
||||
# 获取 JWT
|
||||
JWT=$(curl -sf -X POST "http://localhost:3334/admin/api/login" \
|
||||
-H "Content-Type: application/json" -d '{"password": "password"}' | \
|
||||
python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
|
||||
|
||||
# 更新三个关键配置
|
||||
curl -sf -X PUT "http://localhost:3334/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $JWT" \
|
||||
-d '{
|
||||
"GITEA_API_URL": "http://gitea:3000/api/v1",
|
||||
"GITEA_ACCESS_TOKEN": "<token>",
|
||||
"WEBHOOK_SECRET": "e2e-test-secret"
|
||||
}'
|
||||
```
|
||||
|
||||
**关键**:
|
||||
- `GITEA_API_URL` 必须是 `http://gitea:3000/api/v1`(Docker 内部地址),不是 `localhost` 或宿主机地址
|
||||
- `GITEA_ACCESS_TOKEN` 是 seed.sh 生成的 token,assistant 用它 clone 仓库和发布评论
|
||||
- `WEBHOOK_SECRET` 必须与 Gitea webhook 的 secret 一致,否则签名验证失败
|
||||
|
||||
### 5. 触发 PR 审查
|
||||
|
||||
PR 创建时 Gitea 会自动触发 webhook。如果需要手动触发:
|
||||
|
||||
```bash
|
||||
# 获取 PR 信息
|
||||
PR_RESP=$(curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1" \
|
||||
-H "Authorization: token <token>")
|
||||
|
||||
HEAD_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['head']['sha'])")
|
||||
BASE_SHA=$(echo "$PR_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['base']['sha'])")
|
||||
|
||||
# 构造 webhook payload(需包含 head/base SHA)
|
||||
cat > /tmp/webhook_payload.json << EOF
|
||||
{
|
||||
"action": "opened",
|
||||
"number": 1,
|
||||
"pull_request": {
|
||||
"number": 1,
|
||||
"title": "feat: add user handler",
|
||||
"head": { "ref": "feature/add-user-handler", "sha": "$HEAD_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } },
|
||||
"base": { "ref": "main", "sha": "$BASE_SHA",
|
||||
"repo": { "clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git" } }
|
||||
},
|
||||
"repository": {
|
||||
"full_name": "e2e-admin/e2e-test-repo",
|
||||
"name": "e2e-test-repo",
|
||||
"owner": { "login": "e2e-admin" },
|
||||
"clone_url": "http://gitea:3000/e2e-admin/e2e-test-repo.git"
|
||||
},
|
||||
"sender": { "login": "e2e-admin" }
|
||||
}
|
||||
EOF
|
||||
|
||||
# 计算 HMAC 签名(注意:必须基于文件内容计算,避免 shell 变量传递时改变内容)
|
||||
SIG=$(cat /tmp/webhook_payload.json | openssl dgst -sha256 -hmac "e2e-test-secret" | awk '{print $NF}')
|
||||
|
||||
# 发送 webhook(⚠️ 必须用 --data-binary 而非 -d,否则换行符被剥离导致签名不匹配)
|
||||
curl -s -X POST "http://localhost:3334/webhook/gitea" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Gitea-Event: pull_request" \
|
||||
-H "X-Gitea-Signature: ${SIG}" \
|
||||
--data-binary @/tmp/webhook_payload.json
|
||||
```
|
||||
|
||||
### 6. 验证审查结果
|
||||
|
||||
```bash
|
||||
# 等待审查完成
|
||||
sleep 10
|
||||
|
||||
# 检查 assistant 日志
|
||||
docker logs e2e-assistant 2>&1 | grep -E "审查|publish|评论|finding|ERROR" | tail -20
|
||||
|
||||
# 通过 API 查看 run 详情
|
||||
curl -sf "http://localhost:3334/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer $JWT" | python3 -m json.tool | head -30
|
||||
|
||||
# 检查 Gitea PR 评论(summary)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/issues/1/comments" \
|
||||
-H "Authorization: token <token>" | python3 -c "
|
||||
import sys,json
|
||||
for c in json.load(sys.stdin):
|
||||
print(c['body'][:200])
|
||||
"
|
||||
|
||||
# 检查 Gitea PR Reviews(行级评论)
|
||||
curl -sf "http://localhost:3333/api/v1/repos/e2e-admin/e2e-test-repo/pulls/1/reviews" \
|
||||
-H "Authorization: token <token>"
|
||||
```
|
||||
|
||||
## 验证检查清单
|
||||
|
||||
| # | 检查项 | 验证方式 |
|
||||
|---|--------|----------|
|
||||
| 1 | Gitea 容器 healthy | `docker ps` 或 `curl localhost:3333/api/v1/version` |
|
||||
| 2 | Assistant 容器 healthy | `curl localhost:3334/api/health` |
|
||||
| 3 | Webhook 签名验证通过 | assistant 日志无"签名验证失败" |
|
||||
| 4 | Git clone mirror 成功 | assistant 日志无"could not read Username" |
|
||||
| 5 | Agent 审查执行完成 | run status = `succeeded` |
|
||||
| 6 | Subagent 被触发 | sessionTree.invocations 非空 |
|
||||
| 7 | Findings 数量 > 0 | run details 中 findings 非空 |
|
||||
| 8 | Summary 评论发布到 Gitea | PR issue comments 包含"AI Agent代码审查结果" |
|
||||
| 9 | 行级评论发布到 Gitea | PR reviews 包含 COMMENT 类型 |
|
||||
| 10 | Finding published=true | DB 中 finding.published = true |
|
||||
|
||||
## Mock LLM vs 真实 LLM
|
||||
|
||||
| 特性 | E2E_MOCK_LLM=1 | 真实 LLM |
|
||||
|------|----------------|----------|
|
||||
| 模型 | `RuntimeE2EMockLLM`(脚本驱动) | OpenAI/Anthropic/其他 |
|
||||
| Subagent | 必然调用(固定脚本) | 动态决策(根据 diff 复杂度) |
|
||||
| Findings | 固定 1 条(eval 安全问题) | 根据实际代码动态发现 |
|
||||
| 速度 | <1s | 10-60s |
|
||||
| 用途 | 集成链路验证 | 审查质量验证 |
|
||||
|
||||
## 常见问题
|
||||
|
||||
### `ENCRYPTION_KEY is required`
|
||||
**原因**:`docker-compose.e2e.yml` 缺少 `ENCRYPTION_KEY` 环境变量。
|
||||
**修复**:已在 compose 文件中添加默认值。
|
||||
|
||||
### `Webhook签名验证失败`
|
||||
**原因**:请求的 HMAC 签名与 assistant 配置的 `WEBHOOK_SECRET` 不匹配。
|
||||
**修复**:确保 webhook payload 的签名计算使用与 Admin API 配置的 `WEBHOOK_SECRET` 相同的密钥。
|
||||
|
||||
### `could not read Username for 'http://gitea:3000'`
|
||||
**原因**:`GITEA_ACCESS_TOKEN` 未正确配置(默认值 `placeholder`)或 DB 配置中的值不正确。
|
||||
**修复**:通过 Admin API 更新 `GITEA_ACCESS_TOKEN` 为 seed.sh 生成的实际 token。
|
||||
|
||||
### `Gitea is not supposed to be run as root`
|
||||
**原因**:Gitea 容器以 root 运行,但 `gitea admin` 命令不允许 root 执行。
|
||||
**修复**:使用 `docker exec e2e-gitea su git -c "gitea admin user create ..."` 格式。
|
||||
|
||||
### Gitea API URL 指向 localhost
|
||||
**原因**:assistant DB 中 `GITEA_API_URL` 默认值为 `http://localhost:5174/api/v1`(自身地址)。
|
||||
**修复**:通过 Admin API 更新为 `http://gitea:3000/api/v1`(Docker 内部地址)。
|
||||
|
||||
### 评论未发布到 Gitea
|
||||
**原因**:Agent 引擎的 `publishPendingComments` 链路缺失(已修复)。
|
||||
**修复**:确保使用包含 `publishPendingComments` 逻辑的版本。
|
||||
|
||||
## 清理
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.e2e.yml down -v
|
||||
```
|
||||
|
||||
`-v` 会删除 Gitea 数据卷,下次启动需要重新 seed。
|
||||
@@ -1,169 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
E2ETestHarness,
|
||||
type Finding,
|
||||
type Scenario,
|
||||
type SessionDetail,
|
||||
} from './e2e-test-harness';
|
||||
|
||||
function assertFindingsMatchScenario(findings: Finding[], scenario: Scenario): void {
|
||||
expect(findings.length).toBeGreaterThanOrEqual(scenario.minFindings);
|
||||
|
||||
if (scenario.maxFindings !== undefined) {
|
||||
expect(findings.length).toBeLessThanOrEqual(scenario.maxFindings);
|
||||
}
|
||||
|
||||
const highSeverityCount = findings.filter((finding) => finding.severity === 'high').length;
|
||||
expect(highSeverityCount).toBeGreaterThanOrEqual(scenario.minHighSeverity);
|
||||
|
||||
const fingerprints = findings
|
||||
.map((finding) => finding.fingerprint)
|
||||
.filter((value): value is string => Boolean(value));
|
||||
expect(new Set(fingerprints).size).toBe(fingerprints.length);
|
||||
}
|
||||
|
||||
function expectPipelineStepsCompleted(detail: SessionDetail): void {
|
||||
const statusesByKey = new Map(detail.plan.map((step) => [step.key, step.status]));
|
||||
expect(statusesByKey.get('prepare_workspace')).toBe('completed');
|
||||
expect(statusesByKey.get('build_context')).toBe('completed');
|
||||
expect(statusesByKey.get('review:triage')).toBe('completed');
|
||||
expect(statusesByKey.get('review:full_review')).toBe('completed');
|
||||
expect(statusesByKey.get('aggregate_findings')).toBe('completed');
|
||||
expect(statusesByKey.get('publish_review')).toBe('completed');
|
||||
expect(statusesByKey.get('save_reviewed_ref')).toBe('completed');
|
||||
}
|
||||
|
||||
function expectAutonomousFullReviewPipeline(detail: SessionDetail): void {
|
||||
const fullReviewInvocations = detail.subagentInvocations.filter(
|
||||
(invocation) => invocation.subagentName === 'review:full_review'
|
||||
);
|
||||
expect(fullReviewInvocations).toHaveLength(1);
|
||||
expect(fullReviewInvocations[0].status).toBe('completed');
|
||||
expect(detail.checkpoint?.state?.reviewCompleted).toBe(true);
|
||||
expect(detail.checkpoint?.state?.published).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewedRefSaved).toBe(true);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.toolCallNames).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
'read_file',
|
||||
]);
|
||||
expect(detail.checkpoint?.state?.reviewDiagnostics?.stopReason).toBe('modelFinalized');
|
||||
|
||||
const findings = detail.checkpoint?.state?.findings ?? [];
|
||||
expect(findings.length).toBeGreaterThan(0);
|
||||
expect(findings[0].detail).toContain('auth/user model');
|
||||
expect(findings[0].evidence).toContain('src/auth.ts');
|
||||
|
||||
const publishedComments = detail.runDetails?.comments?.filter(
|
||||
(comment) => comment.status === 'published'
|
||||
);
|
||||
expect(publishedComments?.length).toBeGreaterThan(0);
|
||||
expect(publishedComments?.some((comment) => !comment.path)).toBe(true);
|
||||
expect(publishedComments?.some((comment) => comment.path === 'src/user-handler.ts')).toBe(true);
|
||||
}
|
||||
|
||||
describe('E2E Review Flow', () => {
|
||||
const harness = new E2ETestHarness();
|
||||
|
||||
beforeAll(async () => {
|
||||
await harness.start();
|
||||
await harness.seedGitea();
|
||||
}, 90_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.stop();
|
||||
});
|
||||
|
||||
test('核心链路验证: webhook → clone → triage → full_review → aggregate → publish → save ref → Gitea has comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('simple-bug-pr');
|
||||
|
||||
const webhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(webhookResponse.status).toBe('accepted');
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.completed).toBe(true);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.checkpoint?.state?.published).toBe(true);
|
||||
expectAutonomousFullReviewPipeline(result.detail);
|
||||
|
||||
const comments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(comments.length).toBeGreaterThan(0);
|
||||
}, 150_000);
|
||||
|
||||
test('状态正确性: session status transitions and checkpoint consistency', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('security-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const snapshot = await harness.waitForSessionSnapshot(owner, repo, prNumber, 30);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(
|
||||
snapshot.detail.summary.status
|
||||
);
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['queued', 'planning', 'executing', 'completed']).toContain(result.observedStates[0]);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
expect(result.detail.checkpoint?.stopReason).toBe('completed');
|
||||
expect(result.detail.checkpoint?.pendingTasks ?? []).toHaveLength(0);
|
||||
expect(result.detail.summary.findingCount).toBe(harness.extractFindings(result.detail).length);
|
||||
}, 150_000);
|
||||
|
||||
test('Findings 质量: fixtures trigger expected triage modes, autonomous full review, and finding counts', async () => {
|
||||
const fixtureNames = ['simple-bug-pr', 'minimal-change-pr'];
|
||||
|
||||
for (const fixtureName of fixtureNames) {
|
||||
const { owner, repo, prNumber, scenario } = await harness.seedPR(fixtureName);
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(result.sessionState).toBe('completed');
|
||||
|
||||
const triageMode = harness.extractTriageMode(result.detail);
|
||||
if (triageMode !== undefined) {
|
||||
expect(triageMode).toBe(scenario.expectedTriageMode);
|
||||
}
|
||||
|
||||
expectPipelineStepsCompleted(result.detail);
|
||||
expect(result.detail.subagentInvocations).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ subagentName: 'review:full_review', status: 'completed' }),
|
||||
])
|
||||
);
|
||||
|
||||
assertFindingsMatchScenario(harness.extractFindings(result.detail), scenario);
|
||||
}
|
||||
}, 360_000);
|
||||
|
||||
test('幂等性: duplicate webhook does not create duplicate comments', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('duplicate-webhook-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber);
|
||||
const firstResult = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(firstResult.sessionState).toBe('completed');
|
||||
const firstComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
expect(firstComments.length).toBeGreaterThan(0);
|
||||
|
||||
const duplicateWebhookResponse = await harness.triggerWebhook(owner, repo, prNumber);
|
||||
expect(['accepted', 'deduplicated']).toContain(duplicateWebhookResponse.status);
|
||||
const secondResult = await harness.waitForReview(owner, repo, prNumber, 60);
|
||||
expect(secondResult.sessionId).toBe(firstResult.sessionId);
|
||||
const secondComments = await harness.getGiteaComments(owner, repo, prNumber);
|
||||
|
||||
expect(secondComments.length).toBe(firstComments.length);
|
||||
expect(new Set(secondComments.map((comment) => comment.body)).size).toBe(
|
||||
new Set(firstComments.map((comment) => comment.body)).size
|
||||
);
|
||||
}, 180_000);
|
||||
|
||||
test('错误恢复: clone failure marks session failed, not stuck', async () => {
|
||||
const { owner, repo, prNumber } = await harness.seedPR('clean-refactor-pr');
|
||||
|
||||
await harness.triggerWebhook(owner, repo, prNumber, {
|
||||
repositoryPatch: {
|
||||
clone_url: `http://invalid-host-99999.local/${owner}/${repo}-missing.git`,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await harness.waitForReview(owner, repo, prNumber, 120);
|
||||
expect(['completed', 'failed']).toContain(result.sessionState);
|
||||
}, 150_000);
|
||||
});
|
||||
@@ -1,748 +0,0 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const ENCRYPTION_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
|
||||
const WEBHOOK_SECRET = 'e2e-test-webhook-secret';
|
||||
const TERMINAL_STATES = new Set(['completed', 'failed', 'ignored', 'cancelled', 'error']);
|
||||
|
||||
type JsonPrimitive = string | number | boolean | null;
|
||||
type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue };
|
||||
|
||||
interface GiteaUser {
|
||||
login: string;
|
||||
full_name?: string;
|
||||
}
|
||||
|
||||
interface GiteaRepo {
|
||||
id: number;
|
||||
name: string;
|
||||
full_name: string;
|
||||
clone_url: string;
|
||||
html_url: string;
|
||||
ssh_url?: string;
|
||||
owner: GiteaUser;
|
||||
}
|
||||
|
||||
interface GiteaPullRequest {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
html_url: string;
|
||||
head: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
base: {
|
||||
ref: string;
|
||||
sha: string;
|
||||
repo?: GiteaRepo;
|
||||
};
|
||||
requested_reviewers?: GiteaUser[];
|
||||
user?: GiteaUser;
|
||||
}
|
||||
|
||||
interface Scenario {
|
||||
name: string;
|
||||
description: string;
|
||||
expectedTriageMode: string;
|
||||
expectedDomains: string[];
|
||||
minFindings: number;
|
||||
maxFindings?: number;
|
||||
minHighSeverity: number;
|
||||
testIdempotency?: boolean;
|
||||
}
|
||||
|
||||
interface AdminLoginResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface SessionSummary {
|
||||
sessionId: string;
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
status: string;
|
||||
findingCount: number;
|
||||
}
|
||||
|
||||
interface SessionListEntry {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
}
|
||||
|
||||
interface SessionListResponse {
|
||||
data: SessionListEntry[];
|
||||
}
|
||||
|
||||
interface Finding {
|
||||
severity?: string;
|
||||
confidence?: number;
|
||||
path?: string;
|
||||
line?: number;
|
||||
title?: string;
|
||||
detail?: string;
|
||||
evidence?: string;
|
||||
category?: string;
|
||||
domain?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
interface SessionDetail {
|
||||
session: {
|
||||
id: string;
|
||||
metadata?: Record<string, JsonValue>;
|
||||
};
|
||||
summary: SessionSummary;
|
||||
checkpoint: {
|
||||
stopReason?: string;
|
||||
pendingTasks?: Array<{ name: string }>;
|
||||
state?: {
|
||||
targetSha?: string;
|
||||
triage?: {
|
||||
mode?: string;
|
||||
domains?: string[];
|
||||
};
|
||||
triageMode?: string;
|
||||
findings?: Finding[];
|
||||
published?: boolean;
|
||||
reviewedRefSaved?: boolean;
|
||||
reviewCompleted?: boolean;
|
||||
reviewedRef?: string;
|
||||
reviewDiagnostics?: {
|
||||
toolCallNames?: string[];
|
||||
toolCallCount?: number;
|
||||
parsedFindingCount?: number;
|
||||
stopReason?: string;
|
||||
};
|
||||
};
|
||||
} | null;
|
||||
plan: Array<{ key: string; status: string; label: string }>;
|
||||
events: Array<{ eventType: string; payload: Record<string, JsonValue> }>;
|
||||
runDetails: {
|
||||
findings?: Finding[];
|
||||
comments?: Array<{
|
||||
status?: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body?: string;
|
||||
fingerprint?: string;
|
||||
}>;
|
||||
} | null;
|
||||
subagentInvocations: Array<{
|
||||
subagentName: string;
|
||||
status: string;
|
||||
result?: Record<string, JsonValue>;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface GiteaTokenResponse {
|
||||
sha1?: string;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
interface CommentLike {
|
||||
id: number;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
}
|
||||
|
||||
interface SeedResult {
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber: number;
|
||||
scenario: Scenario;
|
||||
}
|
||||
|
||||
interface ReviewWaitResult {
|
||||
completed: boolean;
|
||||
sessionState: string;
|
||||
sessionId: string;
|
||||
detail: SessionDetail;
|
||||
observedStates: string[];
|
||||
}
|
||||
|
||||
interface TriggerWebhookOptions {
|
||||
repositoryPatch?: Partial<GiteaRepo>;
|
||||
action?: string;
|
||||
}
|
||||
|
||||
export class E2ETestHarness {
|
||||
readonly giteaUrl = (process.env.E2E_GITEA_URL ?? 'http://localhost:3333').replace(/\/$/, '');
|
||||
readonly adminUser = process.env.E2E_GITEA_ADMIN_USER ?? 'e2e-admin';
|
||||
readonly adminPass = process.env.E2E_GITEA_ADMIN_PASS ?? 'e2ePassword123!';
|
||||
|
||||
private assistantProcess?: Bun.Subprocess<'pipe', 'pipe', 'pipe'>;
|
||||
private assistantPort = 43100 + Math.floor(Math.random() * 1000);
|
||||
private tempDir = mkdtempSync(path.join(tmpdir(), 'e2e-assistant-'));
|
||||
private databasePath = path.join(this.tempDir, 'assistant.db');
|
||||
private reviewWorkDir = path.join(this.tempDir, 'review-workdir');
|
||||
private adminJwt?: string;
|
||||
private giteaToken?: string;
|
||||
private repoCounter = 0;
|
||||
|
||||
get assistantUrl(): string {
|
||||
return `http://127.0.0.1:${this.assistantPort}`;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.startAssistant();
|
||||
this.adminJwt = await this.getAdminJWT();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.stopAssistant();
|
||||
}
|
||||
|
||||
async startAssistant(): Promise<void> {
|
||||
if (this.assistantProcess) return;
|
||||
|
||||
this.assistantProcess = Bun.spawn(['bun', 'run', 'src/index.ts'], {
|
||||
cwd: path.resolve(import.meta.dir, '../..'),
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
E2E_MOCK_LLM: '1',
|
||||
ENCRYPTION_KEY,
|
||||
DATABASE_PATH: this.databasePath,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
PORT: String(this.assistantPort),
|
||||
LOG_LEVEL: process.env.LOG_LEVEL ?? 'error',
|
||||
},
|
||||
});
|
||||
|
||||
this.drainProcessOutput(this.assistantProcess.stdout, 'assistant stdout');
|
||||
this.drainProcessOutput(this.assistantProcess.stderr, 'assistant stderr');
|
||||
await this.waitForAssistantHealth();
|
||||
}
|
||||
|
||||
stopAssistant(): void {
|
||||
if (this.assistantProcess) {
|
||||
this.assistantProcess.kill();
|
||||
this.assistantProcess = undefined;
|
||||
}
|
||||
|
||||
if (existsSync(this.tempDir)) {
|
||||
rmSync(this.tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async seedGitea(): Promise<void> {
|
||||
await this.waitForGitea();
|
||||
await this.ensureAdminUser();
|
||||
this.giteaToken = await this.createToken();
|
||||
await this.configureAssistant();
|
||||
}
|
||||
|
||||
async seedPR(scenarioName: string): Promise<SeedResult> {
|
||||
if (!this.giteaToken) {
|
||||
await this.seedGitea();
|
||||
}
|
||||
|
||||
const scenario = await this.readScenario(scenarioName);
|
||||
const owner = this.adminUser;
|
||||
const repo = `e2e-${scenarioName.replace(/[^a-z0-9-]/gi, '-')}-${Date.now()}-${this.repoCounter++}`;
|
||||
const baseBranch = 'main';
|
||||
const featureBranch = `feature/${scenarioName}-${this.repoCounter}`;
|
||||
|
||||
await this.createRepo(repo);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
baseBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'base'),
|
||||
`test: seed ${scenario.name} base`
|
||||
);
|
||||
await this.pushBranchWithFiles(
|
||||
owner,
|
||||
repo,
|
||||
featureBranch,
|
||||
await this.readFixtureFiles(scenarioName, 'branch'),
|
||||
`feat: ${scenario.description}`
|
||||
);
|
||||
const pr = await this.createPullRequest(
|
||||
owner,
|
||||
repo,
|
||||
scenario.description,
|
||||
featureBranch,
|
||||
baseBranch
|
||||
);
|
||||
await this.createWebhook(owner, repo);
|
||||
|
||||
return { owner, repo, prNumber: pr.number, scenario };
|
||||
}
|
||||
|
||||
async triggerWebhook(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
options: TriggerWebhookOptions = {}
|
||||
): Promise<{ status: string; runId?: string }> {
|
||||
const repository = await this.giteaFetch<GiteaRepo>(`/repos/${owner}/${repo}`);
|
||||
const pullRequest = await this.giteaFetch<GiteaPullRequest>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}`
|
||||
);
|
||||
const normalizedRepository = this.normalizeRepoUrls({
|
||||
...repository,
|
||||
...options.repositoryPatch,
|
||||
owner: repository.owner,
|
||||
});
|
||||
const payload = {
|
||||
action: options.action ?? 'opened',
|
||||
number: prNumber,
|
||||
pull_request: {
|
||||
...pullRequest,
|
||||
head: {
|
||||
...pullRequest.head,
|
||||
repo: pullRequest.head.repo ? this.normalizeRepoUrls(pullRequest.head.repo) : undefined,
|
||||
},
|
||||
base: {
|
||||
...pullRequest.base,
|
||||
repo: pullRequest.base.repo ? this.normalizeRepoUrls(pullRequest.base.repo) : undefined,
|
||||
},
|
||||
requested_reviewers: pullRequest.requested_reviewers ?? [],
|
||||
},
|
||||
repository: normalizedRepository,
|
||||
sender: repository.owner,
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const signature = createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
|
||||
return this.fetchJson<{ status: string; runId?: string }>(
|
||||
`${this.assistantUrl}/webhook/gitea`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Gitea-Event': 'pull_request',
|
||||
'X-Gitea-Signature': signature,
|
||||
},
|
||||
body,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async waitForReview(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 120
|
||||
): Promise<ReviewWaitResult> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
const observedStates: string[] = [];
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
const status = entry.summary.status;
|
||||
if (observedStates.at(-1) !== status) observedStates.push(status);
|
||||
const detail = await this.getSessionDetail(entry.summary.sessionId);
|
||||
const detailStatus = detail.summary.status;
|
||||
if (observedStates.at(-1) !== detailStatus) observedStates.push(detailStatus);
|
||||
|
||||
if (TERMINAL_STATES.has(detailStatus)) {
|
||||
return {
|
||||
completed: detailStatus === 'completed',
|
||||
sessionState: detailStatus,
|
||||
sessionId: entry.summary.sessionId,
|
||||
detail,
|
||||
observedStates,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
await this.sleep(2000);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Timed out waiting for review ${owner}/${repo}#${prNumber}; observed states: ${observedStates.join(' -> ') || 'none'}`
|
||||
);
|
||||
}
|
||||
|
||||
async waitForSessionSnapshot(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number,
|
||||
timeoutSeconds = 30
|
||||
): Promise<{ entry: SessionListEntry; detail: SessionDetail }> {
|
||||
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const entry = await this.findSession(owner, repo, prNumber);
|
||||
if (entry) {
|
||||
return { entry, detail: await this.getSessionDetail(entry.summary.sessionId) };
|
||||
}
|
||||
await this.sleep(500);
|
||||
}
|
||||
|
||||
throw new Error(`Timed out waiting for session snapshot ${owner}/${repo}#${prNumber}`);
|
||||
}
|
||||
|
||||
async getAdminJWT(): Promise<string> {
|
||||
const response = await this.fetchJson<AdminLoginResponse>(
|
||||
`${this.assistantUrl}/admin/api/login`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'password' }),
|
||||
}
|
||||
);
|
||||
return response.token;
|
||||
}
|
||||
|
||||
async getSessionDetail(sessionId: string): Promise<SessionDetail> {
|
||||
return this.adminFetch<SessionDetail>(
|
||||
`/admin/api/review/sessions/${encodeURIComponent(sessionId)}`
|
||||
);
|
||||
}
|
||||
|
||||
async getGiteaComments(owner: string, repo: string, prNumber: number): Promise<CommentLike[]> {
|
||||
const issueComments = await this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/issues/${prNumber}/comments`
|
||||
);
|
||||
|
||||
const reviews = await this.giteaFetch<{ id: number }[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews`
|
||||
);
|
||||
const reviewCommentLists = await Promise.all(
|
||||
reviews.map((r) =>
|
||||
this.giteaFetch<CommentLike[]>(
|
||||
`/repos/${owner}/${repo}/pulls/${prNumber}/reviews/${r.id}/comments`
|
||||
).catch(() => [] as CommentLike[])
|
||||
)
|
||||
);
|
||||
const reviewComments = reviewCommentLists.flat();
|
||||
|
||||
return [...issueComments, ...reviewComments];
|
||||
}
|
||||
|
||||
extractFindings(detail: SessionDetail): Finding[] {
|
||||
return detail.checkpoint?.state?.findings ?? detail.runDetails?.findings ?? [];
|
||||
}
|
||||
|
||||
extractTriageMode(detail: SessionDetail): string | undefined {
|
||||
return detail.checkpoint?.state?.triage?.mode ?? detail.checkpoint?.state?.triageMode;
|
||||
}
|
||||
|
||||
extractDomains(detail: SessionDetail): string[] {
|
||||
const triageDomains = detail.checkpoint?.state?.triage?.domains;
|
||||
return triageDomains ?? [];
|
||||
}
|
||||
|
||||
private async configureAssistant(): Promise<void> {
|
||||
await this.putConfig({
|
||||
GITEA_API_URL: `${this.giteaUrl}/api/v1`,
|
||||
GITEA_ACCESS_TOKEN: this.requireToken(),
|
||||
GITEA_ADMIN_TOKEN: this.requireToken(),
|
||||
WEBHOOK_SECRET,
|
||||
REVIEW_ENGINE: 'kernel',
|
||||
REVIEW_WORKDIR: this.reviewWorkDir,
|
||||
REVIEW_COMMAND_TIMEOUT_MS: '30000',
|
||||
REVIEW_ALLOWED_COMMANDS: 'git,rg,cat,sed,wc',
|
||||
});
|
||||
}
|
||||
|
||||
private async putConfig(values: Record<string, string>): Promise<void> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
const response = await fetch(`${this.assistantUrl}/admin/api/config`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(values),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to configure assistant: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async findSession(
|
||||
owner: string,
|
||||
repo: string,
|
||||
prNumber: number
|
||||
): Promise<SessionListEntry | undefined> {
|
||||
const payload = await this.adminFetch<SessionListResponse>(
|
||||
'/admin/api/review/sessions?limit=100'
|
||||
);
|
||||
return payload.data.find((entry) => {
|
||||
const metadata = entry.session.metadata ?? {};
|
||||
const metadataOwner = typeof metadata.owner === 'string' ? metadata.owner : undefined;
|
||||
const metadataRepo = typeof metadata.repo === 'string' ? metadata.repo : undefined;
|
||||
const metadataPr =
|
||||
typeof metadata.prNumber === 'number' ? metadata.prNumber : Number(metadata.prNumber);
|
||||
return (
|
||||
(entry.summary.owner ?? metadataOwner) === owner &&
|
||||
(entry.summary.repo ?? metadataRepo) === repo &&
|
||||
(entry.summary.prNumber ?? metadataPr) === prNumber
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async adminFetch<T>(apiPath: string): Promise<T> {
|
||||
const token = this.adminJwt ?? (await this.getAdminJWT());
|
||||
return this.fetchJson<T>(`${this.assistantUrl}${apiPath}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
private async waitForAssistantHealth(): Promise<void> {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.assistantUrl}/api/health`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
}
|
||||
throw new Error(`Assistant did not become healthy at ${this.assistantUrl}`);
|
||||
}
|
||||
|
||||
private async waitForGitea(): Promise<void> {
|
||||
const deadline = Date.now() + 60_000;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/version`);
|
||||
if (response.ok) return;
|
||||
} catch {
|
||||
await this.sleep(2000);
|
||||
}
|
||||
await this.sleep(2000);
|
||||
}
|
||||
throw new Error(`Gitea did not become available at ${this.giteaUrl}`);
|
||||
}
|
||||
|
||||
private async ensureAdminUser(): Promise<void> {
|
||||
const loginCheck = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (loginCheck.ok) return;
|
||||
|
||||
const body = JSON.stringify({
|
||||
username: this.adminUser,
|
||||
password: this.adminPass,
|
||||
email: `${this.adminUser}@e2e-test.local`,
|
||||
must_change_password: false,
|
||||
login_name: this.adminUser,
|
||||
admin_permission: true,
|
||||
});
|
||||
|
||||
for (const [user, pass] of [
|
||||
[this.adminUser, this.adminPass],
|
||||
['root', 'root'],
|
||||
] as const) {
|
||||
const response = await fetch(`${this.giteaUrl}/api/v1/admin/users`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${user}:${pass}`)}`,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
if (response.ok || response.status === 422 || response.status === 409) return;
|
||||
}
|
||||
|
||||
const retryLogin = await fetch(`${this.giteaUrl}/api/v1/user`, {
|
||||
headers: { Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}` },
|
||||
});
|
||||
if (!retryLogin.ok) {
|
||||
throw new Error(
|
||||
`Unable to create or authenticate Gitea admin user: ${retryLogin.status} ${await retryLogin.text()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async createToken(): Promise<string> {
|
||||
const response = await fetch(
|
||||
`${this.giteaUrl}/api/v1/users/${encodeURIComponent(this.adminUser)}/tokens`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Basic ${btoa(`${this.adminUser}:${this.adminPass}`)}`,
|
||||
},
|
||||
body: JSON.stringify({ name: `e2e-token-${Date.now()}`, scopes: ['all'] }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create Gitea token: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as GiteaTokenResponse;
|
||||
const token = payload.sha1 ?? payload.token;
|
||||
if (!token) throw new Error('Gitea token response did not include sha1/token');
|
||||
return token;
|
||||
}
|
||||
|
||||
private async createRepo(name: string): Promise<GiteaRepo> {
|
||||
return this.giteaFetch<GiteaRepo>('/user/repos', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, auto_init: true, default_branch: 'main' }),
|
||||
});
|
||||
}
|
||||
|
||||
private async createPullRequest(
|
||||
owner: string,
|
||||
repo: string,
|
||||
description: string,
|
||||
head: string,
|
||||
base: string
|
||||
): Promise<GiteaPullRequest> {
|
||||
return this.giteaFetch<GiteaPullRequest>(`/repos/${owner}/${repo}/pulls`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
title: `E2E: ${description}`,
|
||||
body: `E2E test PR: ${description}`,
|
||||
head,
|
||||
base,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async createWebhook(owner: string, repo: string): Promise<void> {
|
||||
await this.giteaFetch<JsonValue>(`/repos/${owner}/${repo}/hooks`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'gitea',
|
||||
active: true,
|
||||
events: ['pull_request'],
|
||||
config: {
|
||||
url: `${this.assistantUrl}/webhook/gitea`,
|
||||
content_type: 'json',
|
||||
secret: WEBHOOK_SECRET,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
private async giteaFetch<T>(apiPath: string, init: RequestInit = {}): Promise<T> {
|
||||
return this.fetchJson<T>(`${this.giteaUrl}/api/v1${apiPath}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `token ${this.requireToken()}`,
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchJson<T>(url: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(url, init);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
private async readScenario(scenarioName: string): Promise<Scenario> {
|
||||
const scenarioPath = path.join(this.fixturesDir(), scenarioName, 'scenario.json');
|
||||
return JSON.parse(await readFile(scenarioPath, 'utf-8')) as Scenario;
|
||||
}
|
||||
|
||||
private async readFixtureFiles(
|
||||
scenarioName: string,
|
||||
fixturePart: 'base' | 'branch'
|
||||
): Promise<Record<string, string>> {
|
||||
const dir = path.join(this.fixturesDir(), scenarioName, fixturePart);
|
||||
const files: Record<string, string> = {};
|
||||
const glob = new Bun.Glob('**/*');
|
||||
|
||||
for await (const file of glob.scan({ cwd: dir, onlyFiles: true })) {
|
||||
files[file] = await readFile(path.join(dir, file), 'utf-8');
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
private async pushBranchWithFiles(
|
||||
owner: string,
|
||||
repo: string,
|
||||
branchName: string,
|
||||
files: Record<string, string>,
|
||||
commitMessage: string
|
||||
): Promise<void> {
|
||||
const tmpDir = mkdtempSync(
|
||||
path.join(tmpdir(), `e2e-push-${branchName.replace(/[^a-z0-9-]/gi, '-')}-`)
|
||||
);
|
||||
const cloneUrl = `${this.giteaUrl.replace('http://', `http://${this.adminUser}:${this.adminPass}@`)}/${owner}/${repo}.git`;
|
||||
|
||||
try {
|
||||
await this.exec(['git', 'clone', cloneUrl, tmpDir]);
|
||||
await this.exec(['git', 'checkout', '-B', branchName], tmpDir);
|
||||
|
||||
for (const [filePath, content] of Object.entries(files)) {
|
||||
const destination = path.join(tmpDir, filePath);
|
||||
mkdirSync(path.dirname(destination), { recursive: true });
|
||||
await Bun.write(destination, content);
|
||||
}
|
||||
|
||||
await this.exec(['git', 'config', 'user.email', 'e2e@test.local'], tmpDir);
|
||||
await this.exec(['git', 'config', 'user.name', 'E2E Bot'], tmpDir);
|
||||
await this.exec(['git', 'add', '-A'], tmpDir);
|
||||
await this.exec(['git', 'commit', '-m', commitMessage, '--allow-empty'], tmpDir);
|
||||
await this.exec(['git', 'push', 'origin', branchName, '--force'], tmpDir);
|
||||
} finally {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
private async exec(args: string[], cwd?: string): Promise<void> {
|
||||
const proc = Bun.spawn(args, { cwd, stdout: 'pipe', stderr: 'pipe' });
|
||||
const [stdout, stderr, exitCode] = await Promise.all([
|
||||
new Response(proc.stdout).text(),
|
||||
new Response(proc.stderr).text(),
|
||||
proc.exited,
|
||||
]);
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Command failed (${args.join(' ')}):\n${stdout}\n${stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
private fixturesDir(): string {
|
||||
return path.resolve(import.meta.dir, '../fixtures');
|
||||
}
|
||||
|
||||
private normalizeRepoUrls(repo: GiteaRepo): GiteaRepo {
|
||||
return {
|
||||
...repo,
|
||||
clone_url: this.normalizeGiteaUrl(repo.clone_url),
|
||||
html_url: this.normalizeGiteaUrl(repo.html_url),
|
||||
ssh_url: repo.ssh_url ? this.normalizeGiteaUrl(repo.ssh_url) : repo.ssh_url,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeGiteaUrl(value: string): string {
|
||||
return value.replace('http://gitea:3000', this.giteaUrl);
|
||||
}
|
||||
|
||||
private requireToken(): string {
|
||||
if (!this.giteaToken) throw new Error('Gitea token is not initialized');
|
||||
return this.giteaToken;
|
||||
}
|
||||
|
||||
private drainProcessOutput(stream: ReadableStream<Uint8Array>, label: string): void {
|
||||
void new Response(stream).text().then((output) => {
|
||||
if (output.trim().length > 0 && process.env.E2E_DEBUG === '1') {
|
||||
console.log(`[${label}] ${output}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export type { Finding, ReviewWaitResult, Scenario, SeedResult, SessionDetail };
|
||||
@@ -1,21 +0,0 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
const rounded = Math.round(order.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Order ${order.id}: $${formatted}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
const rounded = Math.round(invoice.total * 100) / 100;
|
||||
const formatted = rounded.toFixed(2);
|
||||
return `Invoice ${invoice.id}: $${formatted}`;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
interface Order {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
function formatCurrency(total: number): string {
|
||||
const rounded = Math.round(total * 100) / 100;
|
||||
return rounded.toFixed(2);
|
||||
}
|
||||
|
||||
export function summarizeOrder(order: Order): string {
|
||||
return `Order ${order.id}: $${formatCurrency(order.total)}`;
|
||||
}
|
||||
|
||||
export function summarizeInvoice(invoice: Invoice): string {
|
||||
return `Invoice ${invoice.id}: $${formatCurrency(invoice.total)}`;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "clean-refactor-pr",
|
||||
"description": "正确的重构",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
export function startApp(): string {
|
||||
return 'sunny-cactus app started';
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
console.log(startApp());
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
# Sunny Cactus Demo
|
||||
|
||||
This fixture updates documentation only. It explains how to start the sample app and does not change runtime behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
Run the application entrypoint and verify that it prints a startup message.
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "docs-only-pr",
|
||||
"description": "纯文档变更",
|
||||
"expectedTriageMode": "skip",
|
||||
"expectedDomains": [],
|
||||
"minFindings": 0,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "duplicate-webhook-pr",
|
||||
"description": "重复webhook幂等性测试",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0,
|
||||
"testIdempotency": true
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score < 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score > 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export function normalizeScore(score: number): number {
|
||||
if (score <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (score >= 100) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.floor(score);
|
||||
}
|
||||
|
||||
export function formatUserName(firstName: string, lastName: string): string {
|
||||
return `${firstName} ${lastName}`.trim();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"name": "minimal-change-pr",
|
||||
"description": "单文件微量变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 0,
|
||||
"maxFindings": 3,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && token === expectedToken;
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
}
|
||||
|
||||
const FALLBACK_ADMIN_TOKEN = 'admin-super-secret-token';
|
||||
|
||||
export function verifyToken(token: string, expectedToken: string): boolean {
|
||||
return token.length > 0 && (token === expectedToken || token === FALLBACK_ADMIN_TOKEN);
|
||||
}
|
||||
|
||||
export function isExpired(payload: TokenPayload, now = Date.now()): boolean {
|
||||
return payload.exp * 1000 <= now;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "security-pr",
|
||||
"description": "安全相关变更",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 1,
|
||||
"minHighSeverity": 0
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'user' | 'admin';
|
||||
}
|
||||
|
||||
const users = new Map<string, User>([
|
||||
['token-user', { id: 'u1', name: 'Alice', role: 'user' }],
|
||||
['token-admin', { id: 'u2', name: 'Bob', role: 'admin' }],
|
||||
]);
|
||||
|
||||
export function authenticate(token: string): User | null {
|
||||
if (!token.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return users.get(token) ?? null;
|
||||
}
|
||||
|
||||
export function requireAdmin(user: User | null): boolean {
|
||||
return user?.role === 'admin';
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { User } from './auth';
|
||||
|
||||
interface UserRecord {
|
||||
id: string;
|
||||
email: string;
|
||||
profile?: {
|
||||
displayName?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface Database {
|
||||
query<T = unknown>(sql: string): Promise<T[]>;
|
||||
}
|
||||
|
||||
export async function getUserDisplayName(user: UserRecord | null): Promise<string> {
|
||||
return user.profile!.displayName!.toUpperCase();
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<UserRecord | null> {
|
||||
const rows = await db.query<UserRecord>(`SELECT * FROM users WHERE email = '${email}'`);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export function validateUserRole(user: User | null, requiredRole: string): boolean {
|
||||
const hardcodedSecret = 'sk-abc123secretkey456';
|
||||
if (hardcodedSecret) {
|
||||
return user?.role === requiredRole;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function deleteUser(users: Map<string, User>, userId: string): Map<string, User> {
|
||||
const user = users.get(userId);
|
||||
if (user!.role === 'admin') {
|
||||
throw new Error('Cannot delete admin user');
|
||||
}
|
||||
users.delete(userId);
|
||||
return users;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "simple-bug-pr",
|
||||
"description": "包含空指针、SQL注入、硬编码密钥的PR",
|
||||
"expectedTriageMode": "light",
|
||||
"expectedDomains": ["correctness"],
|
||||
"minFindings": 2,
|
||||
"minHighSeverity": 1
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { createMockChatForRole, isE2EMockActive } from './llm-mock';
|
||||
|
||||
describe('LLM Mock', () => {
|
||||
test('specialist role returns preset findings', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('specialist', {
|
||||
messages: [
|
||||
{ role: 'system', content: 'You are a code reviewer' },
|
||||
{ role: 'user', content: 'Review this code' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(response.finishReason).toBe('stop');
|
||||
expect(response.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.findings).toBeDefined();
|
||||
expect(parsed.findings.length).toBeGreaterThanOrEqual(1);
|
||||
expect(parsed.findings[0].severity).toBe('high');
|
||||
expect(parsed.findings[0].path).toBe('src/user-handler.ts');
|
||||
});
|
||||
|
||||
test('specialist role simulates autonomous search and cross-file reads when tools are available', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const tools = [
|
||||
{
|
||||
name: 'search_code',
|
||||
description: 'search',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
{ name: 'read_file', description: 'read', parameters: { type: 'object', properties: {} } },
|
||||
];
|
||||
const messages = [
|
||||
{ role: 'system' as const, content: 'You are a code reviewer' },
|
||||
{ role: 'user' as const, content: 'Review this code' },
|
||||
];
|
||||
|
||||
const turn1 = await mock('specialist', { messages, tools });
|
||||
expect(turn1.finishReason).toBe('tool_calls');
|
||||
expect(turn1.toolCalls.map((toolCall) => toolCall.name)).toEqual(['search_code']);
|
||||
|
||||
const turn2 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'assistant', content: '', toolCalls: turn1.toolCalls },
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn2.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn2.toolCalls[0].arguments)).toEqual({ file_path: 'src/user-handler.ts' });
|
||||
|
||||
const turn3 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn3.toolCalls.map((toolCall) => toolCall.name)).toEqual(['read_file']);
|
||||
expect(JSON.parse(turn3.toolCalls[0].arguments)).toEqual({ file_path: 'src/auth.ts' });
|
||||
|
||||
const turn4 = await mock('specialist', {
|
||||
messages: [
|
||||
...messages,
|
||||
{ role: 'tool', toolCallId: 'e2e_search_user_handler', content: '{"matches":[]}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_caller', content: '{"path":"src/user-handler.ts"}' },
|
||||
{ role: 'tool', toolCallId: 'e2e_read_callee', content: '{"path":"src/auth.ts"}' },
|
||||
],
|
||||
tools,
|
||||
});
|
||||
expect(turn4.finishReason).toBe('stop');
|
||||
expect(turn4.toolCalls).toEqual([]);
|
||||
const parsed = JSON.parse(turn4.content!);
|
||||
expect(parsed.findings[0].detail).toContain('auth/user model');
|
||||
expect(parsed.findings[0].evidence).toContain('src/auth.ts');
|
||||
});
|
||||
|
||||
test('planner role returns preset summary', async () => {
|
||||
const mock = createMockChatForRole();
|
||||
const response = await mock('planner', {
|
||||
messages: [{ role: 'user', content: 'Summarize this diff' }],
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(response.content!);
|
||||
expect(parsed.summary).toBeDefined();
|
||||
expect(parsed.keyConcerns).toBeDefined();
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns true when E2E_MOCK_LLM=1', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = '1';
|
||||
expect(isE2EMockActive()).toBe(true);
|
||||
process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
|
||||
test('isE2EMockActive returns false when E2E_MOCK_LLM is not set', () => {
|
||||
const orig = process.env.E2E_MOCK_LLM;
|
||||
process.env.E2E_MOCK_LLM = undefined;
|
||||
expect(isE2EMockActive()).toBe(false);
|
||||
if (orig !== undefined) process.env.E2E_MOCK_LLM = orig;
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export { createMockChatForRole, isE2EMockActive } from '../src/llm/e2e-mock';
|
||||
59
e2e/seed.sh
59
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec -u git e2e-gitea gitea admin user create \
|
||||
--username "${ADMIN_USER}" \
|
||||
--password "${ADMIN_PASS}" \
|
||||
--email "${ADMIN_EMAIL}" \
|
||||
--admin \
|
||||
--must-change-password=false 2>/dev/null || echo " 用户已存在,跳过"
|
||||
docker exec e2e-gitea su git -c "gitea admin user create \
|
||||
--username '${ADMIN_USER}' \
|
||||
--password '${ADMIN_PASS}' \
|
||||
--email '${ADMIN_EMAIL}' \
|
||||
--admin \
|
||||
--must-change-password=false" 2>/dev/null || echo " 用户已存在,跳过"
|
||||
|
||||
echo "=== [3/6] 生成 API Token ==="
|
||||
TOKEN_RESPONSE=$(curl -sf -X POST "${GITEA_URL}/api/v1/users/${ADMIN_USER}/tokens" \
|
||||
@@ -120,43 +120,35 @@ ADMIN_DEFAULT_PASS="password"
|
||||
|
||||
# Wait for assistant to be healthy
|
||||
for i in $(seq 1 20); do
|
||||
if curl -sf "${ASSISTANT_URL}/api/health" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
if curl -sf "${ASSISTANT_URL}/" > /dev/null 2>&1; then
|
||||
echo " Assistant 已就绪"
|
||||
break
|
||||
fi
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
echo " 等待 Assistant... ($i/20)"
|
||||
sleep 3
|
||||
done
|
||||
|
||||
# Login to get JWT (正确路径: /admin/api/login)
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\": \"${ADMIN_DEFAULT_PASS}\"}" 2>/dev/null || true)
|
||||
ADMIN_JWT=$(echo "${LOGIN_RESP}" | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || true)
|
||||
|
||||
if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
|
||||
# 逐项配置(避免 JSON 格式化问题)
|
||||
set_assistant_config() {
|
||||
local key="$1" value="$2"
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{\"${key}\": \"${value}\"}" > /dev/null 2>&1
|
||||
}
|
||||
|
||||
set_assistant_config "WEBHOOK_SECRET" "${WEBHOOK_SECRET}"
|
||||
set_assistant_config "GITEA_API_URL" "http://gitea:3000/api/v1"
|
||||
set_assistant_config "GITEA_ACCESS_TOKEN" "${GITEA_TOKEN}"
|
||||
set_assistant_config "REVIEW_ENGINE" "kernel"
|
||||
set_assistant_config "REVIEW_ENABLE_HUMAN_GATE" "false"
|
||||
set_assistant_config "REVIEW_ALLOWED_COMMANDS" "git,rg,cat,sed,wc"
|
||||
set_assistant_config "REVIEW_COMMAND_TIMEOUT_MS" "30000"
|
||||
|
||||
echo " Assistant 配置完成(含 Gitea 连接参数)"
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
\"WEBHOOK_SECRET\": \"${WEBHOOK_SECRET}\",
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
echo "=== [6/7] 配置 Webhook ==="
|
||||
@@ -213,5 +205,6 @@ echo " PR: #${PR_NUMBER}"
|
||||
echo " Token: ${GITEA_TOKEN:0:8}..."
|
||||
echo ""
|
||||
echo "下一步:"
|
||||
echo " 1. 触发 PR webhook 或推送 feature 分支新提交"
|
||||
echo " 2. 运行 E2E 测试: bun run test:e2e"
|
||||
echo " 1. 更新 assistant 容器的 GITEA_ACCESS_TOKEN:"
|
||||
echo " E2E_GITEA_TOKEN=${GITEA_TOKEN} docker compose -f docker-compose.e2e.yml up -d assistant"
|
||||
echo " 2. 运行测试: ./e2e/test.sh"
|
||||
|
||||
179
e2e/test.sh
179
e2e/test.sh
@@ -2,7 +2,6 @@
|
||||
set -euo pipefail
|
||||
|
||||
# E2E Test Script
|
||||
# 验证 AI 代码审查是否在 PR 上产生了评论
|
||||
#
|
||||
# 前置条件:
|
||||
# 1. docker compose -f docker-compose.e2e.yml up -d
|
||||
@@ -17,10 +16,12 @@ fi
|
||||
|
||||
source "${ENV_FILE}"
|
||||
|
||||
MAX_WAIT=180 # 最多等待 3 分钟
|
||||
MAX_WAIT=240
|
||||
POLL_INTERVAL=5
|
||||
PASS=0
|
||||
FAIL=0
|
||||
RUN_ID=""
|
||||
LATEST_DETAIL='{}'
|
||||
|
||||
echo "=== E2E 测试开始 ==="
|
||||
echo " Gitea: ${GITEA_URL}"
|
||||
@@ -38,6 +39,12 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ "${E2E_MOCK_LLM:-}" = "1" ]; then
|
||||
echo " E2E_MOCK_LLM=1 (shell env)"
|
||||
else
|
||||
echo " E2E_MOCK_LLM 由 assistant 容器环境决定(docker-compose.e2e.yml 已配置)"
|
||||
fi
|
||||
|
||||
# ─── 测试 2: Gitea API 可用 ───
|
||||
echo "[TEST 2] Gitea API 可用性"
|
||||
VERSION=$(curl -sf "${GITEA_URL}/api/v1/version" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version','unknown'))" 2>/dev/null || echo "unknown")
|
||||
@@ -63,69 +70,121 @@ else
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 测试 4: 等待 AI 审查评论出现 ───
|
||||
echo "[TEST 4] AI 审查评论(最多等待 ${MAX_WAIT}s)"
|
||||
COMMENT_FOUND=false
|
||||
WAITED=0
|
||||
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
AI_COMMENTS=$(echo "${COMMENTS}" | python3 -c "
|
||||
import sys, json
|
||||
comments = json.load(sys.stdin)
|
||||
ai = [c for c in comments if 'AI' in c.get('body', '') or 'Agent' in c.get('body', '')]
|
||||
print(len(ai))
|
||||
" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${AI_COMMENTS}" -gt "0" ]; then
|
||||
COMMENT_FOUND=true
|
||||
echo " ✅ PASS: 发现 ${AI_COMMENTS} 条 AI 审查评论 (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
|
||||
echo " ⏳ 等待中... (${WAITED}/${MAX_WAIT}s, 已有评论: $(echo "${COMMENTS}" | python3 -c 'import sys,json; print(len(json.load(sys.stdin)))' 2>/dev/null || echo 0))"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
if [ "${COMMENT_FOUND}" = false ]; then
|
||||
echo " ❌ FAIL: ${MAX_WAIT}s 内未发现 AI 审查评论"
|
||||
FAIL=$((FAIL + 1))
|
||||
|
||||
echo " --- 调试信息 ---"
|
||||
echo " PR 所有评论:"
|
||||
curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
|
||||
echo " Assistant review runs:"
|
||||
curl -sf "${ASSISTANT_URL}/admin/api/review/runs" 2>/dev/null | python3 -m json.tool 2>/dev/null || echo " (无法获取)"
|
||||
fi
|
||||
|
||||
# ─── 测试 5: Review Run 状态检查 ───
|
||||
echo "[TEST 5] Review Run 状态"
|
||||
echo "[TEST 4] Admin 登录"
|
||||
ADMIN_JWT=$(curl -sf -X POST "${ASSISTANT_URL}/admin/api/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"password":"password"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('token',''))" 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "${ADMIN_JWT}" ]; then
|
||||
echo " ✅ PASS: Admin JWT 获取成功"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Admin JWT 获取失败"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 5] 等待 review run 产出并成功(最多等待 ${MAX_WAIT}s)"
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "[]")
|
||||
RUN_COUNT=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('data',d if isinstance(d,list) else [])))" 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${RUN_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: 发现 ${RUN_COUNT} 个 review run(s)"
|
||||
PASS=$((PASS + 1))
|
||||
WAITED=0
|
||||
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
|
||||
RUNS=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo "{}")
|
||||
RUN_ID=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0]['id'] if runs else '')" 2>/dev/null || echo "")
|
||||
RUN_STATUS=$(echo "${RUNS}" | python3 -c "import sys,json; d=json.load(sys.stdin); runs=d.get('data', d if isinstance(d,list) else []); print(runs[0].get('status','') if runs else '')" 2>/dev/null || echo "")
|
||||
if [ -n "${RUN_ID}" ] && [ "${RUN_STATUS}" = "succeeded" ]; then
|
||||
echo " ✅ PASS: run=${RUN_ID} status=succeeded (${WAITED}s)"
|
||||
PASS=$((PASS + 1))
|
||||
break
|
||||
fi
|
||||
echo " ⏳ 等待 run... (${WAITED}/${MAX_WAIT}s, run=${RUN_ID:-none}, status=${RUN_STATUS:-none})"
|
||||
sleep ${POLL_INTERVAL}
|
||||
WAITED=$((WAITED + POLL_INTERVAL))
|
||||
done
|
||||
|
||||
echo "${RUNS}" | python3 -c "
|
||||
import sys, json
|
||||
data = json.load(sys.stdin)
|
||||
runs = data.get('data', data if isinstance(data, list) else data.get('runs', []))
|
||||
for r in runs[:3]:
|
||||
print(f\" - {r.get('id','?')[:8]}... status={r.get('status','?')} attempts={r.get('attempts','?')}\")
|
||||
" 2>/dev/null || true
|
||||
if [ -z "${RUN_ID}" ]; then
|
||||
echo " ❌ FAIL: 未发现 review run"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
if [ -n "${RUN_ID}" ]; then
|
||||
LATEST_DETAIL=$(curl -sf "${ASSISTANT_URL}/admin/api/review/runs/${RUN_ID}" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" 2>/dev/null || echo '{}')
|
||||
fi
|
||||
|
||||
echo "[TEST 6] 会话树包含主/子 Agent 与工具调用"
|
||||
TREE_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
t=d.get("sessionTree") or {}
|
||||
main_type=t.get("agentType")
|
||||
main_tools=[x.get("toolName") for x in t.get("toolCalls",[])]
|
||||
inv=t.get("invocations",[])
|
||||
has_spawn="spawn_subagent" in main_tools
|
||||
child_ok=False
|
||||
if inv:
|
||||
child=inv[0].get("childSession") or {}
|
||||
child_tools=[x.get("toolName") for x in child.get("toolCalls",[])]
|
||||
child_ok=("search_code" in child_tools and "read_file" in child_tools)
|
||||
print("ok" if (main_type=="review-main-agent" and has_spawn and len(inv)>0 and child_ok) else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${TREE_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: 主会话与子代理调用链存在(含 search_code/read_file)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: 无 review runs"
|
||||
echo " ❌ FAIL: sessionTree 未满足动态子代理断言"
|
||||
echo "${LATEST_DETAIL}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 7] run details 包含 findings 与评论记录"
|
||||
DETAIL_ASSERT=$(echo "${LATEST_DETAIL}" | python3 -c '
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
findings=d.get("findings",[])
|
||||
comments=d.get("comments",[])
|
||||
ok=(len(findings) > 0 and len(comments) > 0)
|
||||
print("ok" if ok else "bad")
|
||||
' 2>/dev/null || echo "bad")
|
||||
|
||||
if [ "${DETAIL_ASSERT}" = "ok" ]; then
|
||||
echo " ✅ PASS: run details 存在 findings/comments"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: run details 缺少 findings 或 comments"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
echo "[TEST 8] Gitea 评论产物(summary + line comments)"
|
||||
ISSUE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/issues/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
LINE_COMMENTS=$(curl -sf "${GITEA_URL}/api/v1/repos/${ADMIN_USER}/${REPO_NAME}/pulls/${PR_NUMBER}/comments" \
|
||||
-H "Authorization: token ${GITEA_TOKEN}" 2>/dev/null || echo "[]")
|
||||
|
||||
SUMMARY_COUNT=$(echo "${ISSUE_COMMENTS}" | python3 -c '
|
||||
import json,sys
|
||||
arr=json.load(sys.stdin)
|
||||
cnt=0
|
||||
for c in arr:
|
||||
body=c.get("body") or ""
|
||||
if "审查" in body or "review" in body.lower() or "AI" in body:
|
||||
cnt += 1
|
||||
print(cnt)
|
||||
' 2>/dev/null || echo "0")
|
||||
LINE_COUNT=$(echo "${LINE_COMMENTS}" | python3 -c 'import json,sys; print(len(json.load(sys.stdin)))' 2>/dev/null || echo "0")
|
||||
|
||||
if [ "${SUMMARY_COUNT}" -gt "0" ] && [ "${LINE_COUNT}" -gt "0" ]; then
|
||||
echo " ✅ PASS: summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT}"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ❌ FAIL: Gitea 评论产物不足(summary=${SUMMARY_COUNT}, line_comments=${LINE_COUNT})"
|
||||
echo " --- issue comments ---"
|
||||
echo "${ISSUE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
echo " --- line comments ---"
|
||||
echo "${LINE_COMMENTS}" | python3 -m json.tool 2>/dev/null || true
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
@@ -138,10 +197,10 @@ echo " 失败: ${FAIL}/${TOTAL}"
|
||||
|
||||
if [ ${FAIL} -gt 0 ]; then
|
||||
echo ""
|
||||
echo "⚠️ 部分测试失败。如果 AI 评论测试失败,请确保:"
|
||||
echo " 1. OPENAI_API_KEY 已正确配置"
|
||||
echo " 2. assistant 容器的 GITEA_ACCESS_TOKEN 已设置为 seed 生成的 token"
|
||||
echo " 3. Webhook 已正确触发(检查 Gitea webhook 日志)"
|
||||
echo "⚠️ 部分测试失败。请检查:"
|
||||
echo " 1. docker compose e2e 容器均 healthy"
|
||||
echo " 2. assistant 容器环境含 E2E_MOCK_LLM=1 与正确 GITEA_ACCESS_TOKEN"
|
||||
echo " 3. webhook 已触发且 run details 可见 sessionTree/findings/comments"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
|
||||
@@ -2,11 +2,11 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { useAuth } from './hooks/useAuth';
|
||||
import { LoginPage } from './pages/LoginPage';
|
||||
import DashboardPage from './pages/DashboardPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { RepositoryManager } from './components/RepositoryManager';
|
||||
import { ConfigManager } from './components/ConfigManager';
|
||||
import { NotificationConfigPage } from './components/NotificationConfigPage';
|
||||
import { ReviewConfigPage } from './components/ReviewConfigPage';
|
||||
import ReviewSessionsPage from './pages/ReviewSessionsPage';
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { useTheme } from 'next-themes'
|
||||
import { ColorPaletteProvider } from './hooks/useColorPalette';
|
||||
@@ -50,13 +50,13 @@ function AppContent() {
|
||||
</AuthGuard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/sessions" replace />} />
|
||||
<Route path="sessions" element={<ReviewSessionsPage />} />
|
||||
<Route index element={<Navigate to="/repos" replace />} />
|
||||
<Route path="repos" element={<RepositoryManager />} />
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="*" element={<Navigate to="/sessions" replace />} />
|
||||
<Route path="review-runs" element={<ReviewSessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
<Toaster theme={resolvedTheme === 'dark' ? 'dark' : 'light'} />
|
||||
|
||||
@@ -17,7 +17,7 @@ import { toast } from 'sonner';
|
||||
// Engine-specific field visibility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type EngineMode = 'kernel' | 'codex';
|
||||
type EngineMode = 'agent' | 'codex';
|
||||
|
||||
/** The engine selector field — always visible at the top. */
|
||||
const ENGINE_FIELD = 'REVIEW_ENGINE';
|
||||
@@ -30,15 +30,13 @@ const AGENT_SHARED_FIELDS = new Set([
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
]);
|
||||
|
||||
const KERNEL_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
'REVIEW_ALLOWED_COMMANDS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
'ENABLE_TRIAGE',
|
||||
]);
|
||||
|
||||
/** Fields specific to codex mode only. */
|
||||
@@ -61,8 +59,8 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
|
||||
return fields.filter((f) => {
|
||||
if (f.envKey === ENGINE_FIELD) return false; // rendered separately
|
||||
switch (engine) {
|
||||
case 'kernel':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || KERNEL_ONLY_FIELDS.has(f.envKey);
|
||||
case 'agent':
|
||||
return AGENT_SHARED_FIELDS.has(f.envKey) || AGENT_ONLY_FIELDS.has(f.envKey);
|
||||
case 'codex':
|
||||
return CODEX_FIELDS.has(f.envKey);
|
||||
default:
|
||||
@@ -76,7 +74,7 @@ function getVisibleFields(engine: EngineMode, fields: ConfigFieldDto[]): ConfigF
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const ENGINE_OPTIONS: { value: EngineMode; label: string; description: string }[] = [
|
||||
{ value: 'kernel', label: 'Kernel', description: 'PR Session + Agentic Loop 审查' },
|
||||
{ value: 'agent', label: 'Agent', description: '多代理编排深度审查' },
|
||||
{ value: 'codex', label: 'Codex', description: 'Codex CLI 审查' },
|
||||
];
|
||||
|
||||
@@ -97,14 +95,12 @@ export function ReviewConfigPage() {
|
||||
// Derived: current engine mode
|
||||
const engine: EngineMode = useMemo(() => {
|
||||
const val = localConfig[ENGINE_FIELD];
|
||||
if (val === 'kernel' || val === 'codex') return val;
|
||||
return 'kernel';
|
||||
if (val === 'agent' || val === 'codex') return val;
|
||||
return 'agent';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
|
||||
// Initialize local config from review group
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
@@ -156,7 +152,10 @@ export function ReviewConfigPage() {
|
||||
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
for (const [key, val] of Object.entries(localConfig)) {
|
||||
const fieldsToSave = new Set([ENGINE_FIELD, ...visibleReviewFields.map((field) => field.envKey)]);
|
||||
|
||||
for (const key of fieldsToSave) {
|
||||
const val = localConfig[key];
|
||||
if (typeof val === 'boolean') {
|
||||
payload[key] = val ? 'true' : 'false';
|
||||
} else {
|
||||
@@ -173,9 +172,11 @@ export function ReviewConfigPage() {
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const allOverrideKeys = (reviewGroup?.fields ?? [])
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
.map((f) => f.envKey);
|
||||
if (allOverrideKeys.length === 0) return;
|
||||
if (confirm('确定要重置所有审查配置到默认值吗?这将立即生效。')) {
|
||||
resetMutation.mutate(allOverrideKeys);
|
||||
@@ -189,7 +190,8 @@ export function ReviewConfigPage() {
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
return (reviewGroup?.fields ?? []).some((f) => f.source === 'db');
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
@@ -220,11 +222,11 @@ export function ReviewConfigPage() {
|
||||
const syntheticReviewGroup: ConfigGroupDto | null = reviewGroup
|
||||
? {
|
||||
...reviewGroup,
|
||||
label: engine === 'codex' ? 'Codex 审查设置' : 'Kernel 审查设置',
|
||||
label: engine === 'codex' ? 'Codex 审查设置' : 'Agent 审查设置',
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '基于 PR Session 的 agentic loop 审查引擎配置',
|
||||
: 'Agent 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
@@ -353,7 +355,7 @@ export function ReviewConfigPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine === 'kernel' && (
|
||||
{engine !== 'codex' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
<RoleAssignment />
|
||||
|
||||
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
185
frontend/src/components/__tests__/ReviewConfigPage.test.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ReviewConfigPage } from '../ReviewConfigPage';
|
||||
import { fetchConfig, updateConfig, resetConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
resetConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ProviderList', () => ({
|
||||
ProviderList: () => <div>ProviderListMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/RoleAssignment', () => ({
|
||||
RoleAssignment: () => <div>RoleAssignmentMock</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../llm/ModelCombobox', () => ({
|
||||
ModelCombobox: ({ value, onChange }: { value: string; onChange: (value: string) => void }) => (
|
||||
<input aria-label="Codex model" value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'review',
|
||||
label: '审查引擎',
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'GLOBAL_PROMPT',
|
||||
label: '全局提示词',
|
||||
description: '附加到所有 LLM 调用',
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
value: '',
|
||||
hasValue: false,
|
||||
source: 'default',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_WORKDIR',
|
||||
label: '工作目录',
|
||||
description: 'Agent 模式下本地仓库目录',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: '/tmp/gitea-assistant',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_MAX_PARALLEL_RUNS',
|
||||
label: '最大并发数',
|
||||
description: '单机同时执行的审查任务上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '2',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
label: '允许命令',
|
||||
description: '本地审查沙箱命令白名单',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'git,rg,cat,sed,wc',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
label: '命令超时(ms)',
|
||||
description: '单条本地命令的执行超时时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_TOKEN_BUDGET_LARGE',
|
||||
label: 'Large 令牌预算',
|
||||
description: 'large 规模审查任务的 token 预算上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '120000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'CODEX_MODEL',
|
||||
label: 'Codex 模型',
|
||||
description: 'Codex CLI 使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'o3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ReviewConfigPage', () => {
|
||||
it('shows only current Agent config surface and saves only visible fields', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
vi.mocked(resetConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<ReviewConfigPage />);
|
||||
|
||||
expect(await screen.findByText('Agent 审查设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('REVIEW_COMMAND_TIMEOUT_MS')).toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_TOKEN_BUDGET_LARGE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('REVIEW_ENABLE_HUMAN_GATE')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('ENABLE_TRIAGE')).not.toBeInTheDocument();
|
||||
|
||||
const workdirInput = screen.getByDisplayValue('/tmp/gitea-assistant');
|
||||
await user.clear(workdirInput);
|
||||
await user.type(workdirInput, '/tmp/new-review-workdir');
|
||||
await user.click(screen.getByRole('button', { name: '保存配置' }));
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload.REVIEW_WORKDIR).toBe('/tmp/new-review-workdir');
|
||||
expect(payload.REVIEW_ENGINE).toBe('agent');
|
||||
expect(payload.REVIEW_COMMAND_TIMEOUT_MS).toBe('120000');
|
||||
expect(payload).not.toHaveProperty('REVIEW_TOKEN_BUDGET_LARGE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE');
|
||||
expect(payload).not.toHaveProperty('REVIEW_ENABLE_HUMAN_GATE');
|
||||
expect(payload).not.toHaveProperty('ENABLE_TRIAGE');
|
||||
});
|
||||
});
|
||||
@@ -9,9 +9,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { Edit2, Trash2, Play, Plus, Activity } from 'lucide-react';
|
||||
import {
|
||||
fetchProviders, updateProvider, deleteProvider, testProvider, fetchRoles
|
||||
} from '@/services/llmProviderService';
|
||||
import { fetchProviders, updateProvider, deleteProvider, testProvider } from '@/services/llmProviderService';
|
||||
import type { ProviderDto, TestResult } from '@/services/llmProviderService';
|
||||
import { ProviderDialog } from './ProviderDialog';
|
||||
import { TestResultDialog } from './TestResultDialog';
|
||||
@@ -43,11 +41,6 @@ export function ProviderList() {
|
||||
queryFn: fetchProviders,
|
||||
});
|
||||
|
||||
const { data: roles = [] } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
|
||||
const toggleMutation = useMutation({
|
||||
mutationFn: async ({ id, isEnabled }: { id: string; isEnabled: boolean }) => {
|
||||
return updateProvider(id, { isEnabled });
|
||||
@@ -74,7 +67,6 @@ export function ProviderList() {
|
||||
onSuccess: () => {
|
||||
toast.success('已删除提供商');
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-providers'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
@@ -87,16 +79,8 @@ export function ProviderList() {
|
||||
};
|
||||
|
||||
const handleDelete = (provider: ProviderDto) => {
|
||||
const boundRoles = roles.filter(r => r.providerId === provider.id);
|
||||
if (boundRoles.length > 0) {
|
||||
const roleNames = boundRoles.map(r => r.role).join(', ');
|
||||
if (!window.confirm(`警告:该提供商已绑定到以下角色 (${roleNames})。\n删除后这些角色将失去提供商配置!\n确定要删除吗?`)) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(`确定要删除提供商 "${provider.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
deleteMutation.mutate(provider.id);
|
||||
};
|
||||
|
||||
@@ -1,392 +1,229 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { fetchConfig, updateConfig } from '@/services/configService';
|
||||
import type { ConfigResponse, ConfigFieldDto } from '@/services/configService';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Bot, Route, Save, ShieldCheck, Sparkles, Workflow } from 'lucide-react';
|
||||
import {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
type KernelSubagentDto,
|
||||
} from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: 'Planner', desc: '用于 triage / planning / context compression,负责审查分流与上下文压缩' },
|
||||
specialist: { label: 'Specialist', desc: '用于 correctness / security / quality 等深度审查' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
function getModelRoleBadgeClass(modelRole?: string): string {
|
||||
switch (modelRole) {
|
||||
case 'planner':
|
||||
return 'border-info/30 bg-info/10 text-info';
|
||||
case 'specialist':
|
||||
return 'border-primary/30 bg-primary/10 text-primary';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceBadgeClass(source: KernelSubagentDto['source']): string {
|
||||
switch (source) {
|
||||
case 'built-in':
|
||||
return 'border-primary/20 bg-primary/10 text-primary';
|
||||
case 'plugin':
|
||||
return 'border-warning/20 bg-warning/10 text-warning';
|
||||
case 'custom':
|
||||
return 'border-success/20 bg-success/10 text-success';
|
||||
default:
|
||||
return 'border-border bg-muted/40 text-muted-foreground';
|
||||
}
|
||||
}
|
||||
|
||||
export function RoleAssignment() {
|
||||
const queryClient = useQueryClient();
|
||||
const [roleStates, setRoleStates] = useState<Record<string, RoleState>>({});
|
||||
const [localValues, setLocalValues] = useState<Record<string, string>>({});
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
|
||||
const { data: providers = [] } = useQuery({
|
||||
queryKey: ['llm-providers'],
|
||||
queryFn: fetchProviders,
|
||||
const { data, isLoading, isError, error } = useQuery<ConfigResponse, Error>({
|
||||
queryKey: ['config'],
|
||||
queryFn: fetchConfig,
|
||||
});
|
||||
|
||||
const { data: roles = [], isLoading } = useQuery({
|
||||
queryKey: ['llm-roles'],
|
||||
queryFn: fetchRoles,
|
||||
});
|
||||
const REQUIRED_KEYS = [
|
||||
'AGENT_MAIN_MODEL',
|
||||
'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
'LLM_MAX_CONCURRENT_CALLS',
|
||||
'LLM_RETRY_MAX_ATTEMPTS',
|
||||
'LLM_RETRY_BASE_DELAY_MS',
|
||||
];
|
||||
|
||||
const { data: subagents = [], isLoading: isSubagentsLoading } = useQuery({
|
||||
queryKey: ['kernel-subagents'],
|
||||
queryFn: fetchKernelSubagents,
|
||||
});
|
||||
const fieldsMap = useMemo(() => {
|
||||
if (!data) return new Map<string, ConfigFieldDto>();
|
||||
const map = new Map<string, ConfigFieldDto>();
|
||||
data.groups.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
map.set(field.envKey, field);
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (roles.length > 0) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
roles.forEach(role => {
|
||||
initial[role.role] = {
|
||||
providerId: role.providerId,
|
||||
model: role.model || '',
|
||||
};
|
||||
});
|
||||
ROLES.forEach(r => {
|
||||
if (!initial[r]) {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
if (data) {
|
||||
const initialValues: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
if (field) {
|
||||
initialValues[key] = String(field.value ?? field.defaultValue ?? '');
|
||||
} else {
|
||||
initialValues[key] = '';
|
||||
}
|
||||
});
|
||||
setRoleStates(initial);
|
||||
} else if (!isLoading) {
|
||||
const initial: Record<string, RoleState> = {};
|
||||
ROLES.forEach(r => {
|
||||
initial[r] = { providerId: null, model: '' };
|
||||
});
|
||||
setRoleStates(initial);
|
||||
setLocalValues(initialValues);
|
||||
setIsDirty(false);
|
||||
}
|
||||
}, [roles, isLoading]);
|
||||
}, [data, fieldsMap]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async ({ role, providerId, model }: { role: string; providerId: string | null; model: string | null }) => {
|
||||
return setRole(role, providerId, model);
|
||||
mutationFn: (configData: Record<string, string>) => updateConfig(configData),
|
||||
onSuccess: () => {
|
||||
toast.success('智能体模型设置已保存');
|
||||
queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
toast.success(`${ROLE_LABELS[data.role]?.label || data.role} 角色配置已保存`);
|
||||
queryClient.invalidateQueries({ queryKey: ['llm-roles'] });
|
||||
onError: (err: Error) => {
|
||||
toast.error(`保存失败: ${err.message}`);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { response?: { data?: { error?: string } }; message?: string };
|
||||
toast.error(`保存失败: ${err?.response?.data?.error || err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
const handleProviderChange = (role: string, providerId: string) => {
|
||||
const provider = providers.find(p => p.id === providerId);
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: {
|
||||
providerId,
|
||||
model: provider?.defaultModel || ''
|
||||
}
|
||||
}));
|
||||
const handleFieldChange = (key: string, value: string) => {
|
||||
setLocalValues((prev) => ({ ...prev, [key]: value }));
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
const handleModelChange = (role: string, model: string) => {
|
||||
setRoleStates(prev => ({
|
||||
...prev,
|
||||
[role]: { ...prev[role], model }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = (role: string) => {
|
||||
const state = roleStates[role];
|
||||
if (!state.providerId) {
|
||||
return toast.error('请选择提供商');
|
||||
}
|
||||
if (!state.model) {
|
||||
return toast.error('请输入模型名称');
|
||||
}
|
||||
saveMutation.mutate({
|
||||
role,
|
||||
providerId: state.providerId,
|
||||
model: state.model,
|
||||
const handleSave = () => {
|
||||
const payload: Record<string, string> = {};
|
||||
REQUIRED_KEYS.forEach((key) => {
|
||||
payload[key] = localValues[key] ?? '';
|
||||
});
|
||||
saveMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const enabledProviders = providers.filter(p => p.isEnabled && p.hasKey);
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
加载配置中...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content flex justify-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header pb-4">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="theme-card-content">
|
||||
<div className="theme-error-panel flex items-center gap-3 text-danger">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
<div className="font-medium tracking-wide">加载配置失败: {error.message}</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const missingKeys = REQUIRED_KEYS.filter((key) => !fieldsMap.has(key));
|
||||
|
||||
return (
|
||||
<Card className="gap-0 py-0 theme-card-shell group">
|
||||
<CardHeader className="theme-card-header flex flex-row items-center justify-between pb-4 space-y-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-warning/10 flex items-center justify-center border border-warning/20 group-hover:bg-warning/20 transition-all duration-300">
|
||||
<ShieldCheck className="h-5 w-5 text-warning" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
Subagents 与模型路由
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
上层展示 subagent 目录,下层配置 Planner / Specialist 模型路由
|
||||
</CardDescription>
|
||||
</div>
|
||||
<CardHeader className="theme-card-header pb-4 flex flex-row items-start justify-between space-y-0">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl font-bold text-foreground tracking-tight">
|
||||
智能体模型设置
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
管理智能体运行时的主模型、子模型以及 LLM 调用弹性设置。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
className="theme-interactive-elevate min-w-[100px] bg-primary text-primary-foreground font-bold hover:bg-primary/90 tech-glow transition-all"
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin" /> 保存中...
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
保存设置
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="theme-card-content space-y-8">
|
||||
{/* ── Subagents 目录 ──────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-primary/20 bg-primary/10 text-primary">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<CardContent className="theme-card-content space-y-6">
|
||||
{missingKeys.length > 0 && (
|
||||
<div className="p-3 rounded-lg bg-warning/10 border border-warning/20 text-warning text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">部分配置项在系统中不可用:</span>
|
||||
<span className="font-mono text-xs">{missingKeys.join(', ')}</span>。这些设置将无法编辑或保存。
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">Subagents 目录</h3>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Alert className="border-primary/20 bg-primary/5">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
<AlertTitle>流程编排由 kernel 自动驱动</AlertTitle>
|
||||
<AlertDescription>
|
||||
kernel 根据 session state 与 planner 选择注册式 subagent 执行。下方展示的是当前已注册的 subagent 及其能力标签。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
{REQUIRED_KEYS.map((key) => {
|
||||
const field = fieldsMap.get(key);
|
||||
const isAvailable = !!field;
|
||||
const label = field?.label || key;
|
||||
const description = field?.description || '系统未提供该配置项的描述。';
|
||||
const type = field?.type === 'number' ? 'number' : 'text';
|
||||
|
||||
{isSubagentsLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载 subagent 目录...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Subagents</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">{subagents.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">Built-in</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{subagents.filter((item) => item.source === 'built-in').length}
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`flex flex-col gap-2 p-4 rounded-lg border transition-colors ${
|
||||
isAvailable
|
||||
? 'border-border hover:bg-accent/20'
|
||||
: 'border-dashed border-muted bg-muted/10 opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={key} className="text-base font-semibold text-foreground cursor-pointer">
|
||||
{label}
|
||||
</Label>
|
||||
{!isAvailable && (
|
||||
<Badge variant="outline" className="border-danger/30 text-danger bg-danger/5">
|
||||
不可用
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'db' && (
|
||||
<Badge className="bg-primary/20 text-primary border-primary/30 tech-glow">
|
||||
已配置
|
||||
</Badge>
|
||||
)}
|
||||
{isAvailable && field.source === 'default' && (
|
||||
<Badge variant="outline" className="border-border text-muted-foreground">
|
||||
默认值
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-5">
|
||||
<div className="text-xs uppercase tracking-[0.24em] text-muted-foreground">模型角色</div>
|
||||
<div className="mt-2 text-3xl font-semibold tracking-tight text-foreground">
|
||||
{new Set(subagents.map((item) => item.modelRole).filter(Boolean)).size}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/70 bg-card/70">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="pl-5">Subagent</TableHead>
|
||||
<TableHead>能力定位</TableHead>
|
||||
<TableHead>模型角色</TableHead>
|
||||
<TableHead>标签</TableHead>
|
||||
<TableHead className="pr-5 text-right">状态</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{subagents.map((subagent) => (
|
||||
<TableRow key={subagent.name}>
|
||||
<TableCell className="pl-5 align-top">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground">{subagent.name}</span>
|
||||
<Badge className={getSourceBadgeClass(subagent.source)}>{subagent.source}</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{subagent.description}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="align-top text-sm text-muted-foreground whitespace-normal">
|
||||
{subagent.whenToUse}
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<Badge className={getModelRoleBadgeClass(subagent.modelRole)}>
|
||||
<Route className="h-3 w-3" />
|
||||
{subagent.modelRole ?? '未绑定'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="align-top">
|
||||
<div className="flex flex-wrap gap-1.5 max-w-[260px]">
|
||||
{subagent.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="bg-muted/30">{tag}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="pr-5 align-top text-right">
|
||||
<Badge className={subagent.resumable ? 'border-success/20 bg-success/10 text-success' : 'border-border bg-muted/40 text-muted-foreground'}>
|
||||
{subagent.resumable ? '可恢复' : '一次性'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* ── 模型角色路由 ─────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl border border-warning/25 bg-warning/10 text-warning">
|
||||
<Workflow className="h-4 w-4" />
|
||||
</div>
|
||||
<h3 className="text-base font-semibold text-foreground">模型角色路由</h3>
|
||||
</div>
|
||||
|
||||
<Alert className="border-warning/20 bg-warning/5">
|
||||
<ShieldCheck className="h-4 w-4 text-warning" />
|
||||
<AlertTitle>这里配置的是底层模型路由,不是流程角色编排</AlertTitle>
|
||||
<AlertDescription>
|
||||
Planner / Specialist 决定由哪个 provider/model 响应 LLM 调用。subagent 的注册、标签和执行顺序由 kernel 控制。
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-32 flex items-center justify-center text-muted-foreground gap-2">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
加载模型角色路由...
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-border/50">
|
||||
{ROLES.map(role => {
|
||||
const state = roleStates[role] || { providerId: null, model: '' };
|
||||
const isDirty = roles.find(r => r.role === role)?.providerId !== state.providerId ||
|
||||
(roles.find(r => r.role === role)?.model || '') !== state.model;
|
||||
const consumers = subagents.filter((item) => item.modelRole === role);
|
||||
|
||||
return (
|
||||
<div key={role} className="py-5 px-1">
|
||||
<div className="flex flex-col gap-4 rounded-lg border border-border/60 bg-card/40 p-4 hover:bg-accent/20 transition-colors">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<Badge variant="outline" className="bg-muted/30">
|
||||
{consumers.length} 个 subagent
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
{consumers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-1">
|
||||
{consumers.map((item) => (
|
||||
<Badge key={item.name} className="border-primary/15 bg-primary/5 text-primary">
|
||||
{item.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">提供商</Label>
|
||||
<Select
|
||||
value={state.providerId || ''}
|
||||
onValueChange={(v) => handleProviderChange(role, v)}
|
||||
>
|
||||
<SelectTrigger className="bg-muted/50 border-border text-foreground">
|
||||
<SelectValue placeholder="选择提供商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-popover border-border text-foreground">
|
||||
{enabledProviders.map(p => (
|
||||
<SelectItem key={p.id} value={p.id} description={p.type} className="focus:bg-accent focus:text-primary">
|
||||
{p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{enabledProviders.length === 0 && (
|
||||
<div className="px-2 py-3 text-xs text-danger text-center border-t border-border/60">
|
||||
无可用提供商。请先添加并启用。
|
||||
</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 w-full space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">使用的模型</Label>
|
||||
<ModelCombobox
|
||||
providerType={providers.find(p => p.id === state.providerId)?.type}
|
||||
value={state.model}
|
||||
onChange={(model) => handleModelChange(role, model)}
|
||||
placeholder="选择或输入模型..."
|
||||
disabled={!state.providerId}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-5 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSave(role)}
|
||||
disabled={!isDirty || saveMutation.isPending}
|
||||
variant={isDirty ? 'default' : 'secondary'}
|
||||
className={`transition-all ${isDirty ? 'bg-warning/15 text-warning border border-warning/30 hover:bg-warning/25' : 'bg-muted/50 text-muted-foreground border border-transparent'}`}
|
||||
>
|
||||
<Save className="w-4 h-4 mr-1.5" />
|
||||
{isDirty ? '保存更改' : '已保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</span>
|
||||
<div className="pt-1">
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded-md bg-primary/10 text-primary border border-primary/20 inline-flex items-center">
|
||||
{key}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="flex-1 w-full max-w-xl flex flex-col gap-2">
|
||||
<Input
|
||||
id={key}
|
||||
type={type}
|
||||
value={localValues[key] ?? ''}
|
||||
onChange={(e) => handleFieldChange(key, e.target.value)}
|
||||
disabled={!isAvailable || saveMutation.isPending}
|
||||
placeholder={!isAvailable ? '配置项不可用' : `请输入 ${label}...`}
|
||||
className="bg-muted/50 border-border focus-visible:ring-primary focus-visible:border-primary transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest';
|
||||
import { ProviderList } from '../ProviderList';
|
||||
import {
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
updateProvider,
|
||||
deleteProvider,
|
||||
testProvider,
|
||||
@@ -20,7 +19,6 @@ vi.mock('sonner', () => ({
|
||||
|
||||
vi.mock('@/services/llmProviderService', () => ({
|
||||
fetchProviders: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
updateProvider: vi.fn(),
|
||||
deleteProvider: vi.fn(),
|
||||
testProvider: vi.fn(),
|
||||
@@ -62,7 +60,6 @@ describe('ProviderList', () => {
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([]);
|
||||
vi.mocked(updateProvider).mockResolvedValue({} as never);
|
||||
vi.mocked(deleteProvider).mockResolvedValue(undefined);
|
||||
vi.mocked(testProvider).mockResolvedValue({ success: true });
|
||||
|
||||
@@ -4,12 +4,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RoleAssignment } from '../RoleAssignment';
|
||||
import {
|
||||
fetchKernelSubagents,
|
||||
fetchProviders,
|
||||
fetchRoles,
|
||||
setRole,
|
||||
} from '@/services/llmProviderService';
|
||||
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -18,22 +13,10 @@ vi.mock('sonner', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/services/llmProviderService', async () => {
|
||||
const actual = await vi.importActual<typeof import('@/services/llmProviderService')>('@/services/llmProviderService');
|
||||
return {
|
||||
...actual,
|
||||
fetchProviders: vi.fn(),
|
||||
fetchKernelSubagents: vi.fn(),
|
||||
fetchRoles: vi.fn(),
|
||||
setRole: vi.fn(),
|
||||
fetchModelSuggestions: vi.fn().mockResolvedValue({
|
||||
openai_compatible: ['gpt-4o', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini'],
|
||||
anthropic: ['claude-sonnet-4-20250514'],
|
||||
gemini: ['gemini-2.5-pro'],
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/services/configService', () => ({
|
||||
fetchConfig: vi.fn(),
|
||||
updateConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
@@ -45,84 +28,163 @@ function renderWithQuery(ui: ReactNode) {
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
function makeConfigResponse(): ConfigResponse {
|
||||
return {
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
label: '默认子智能体模型',
|
||||
description: '子智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o-mini',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
label: 'LLM 最大并发调用',
|
||||
description: '同时在飞的 LLM API 调用上限',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '4',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_MAX_ATTEMPTS',
|
||||
label: 'LLM 最大重试次数',
|
||||
description: 'LLM 调用失败时的最大重试次数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '3',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_RETRY_BASE_DELAY_MS',
|
||||
label: 'LLM 重试基础延迟(ms)',
|
||||
description: 'LLM 调用失败重试的基础延迟时间',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
value: '1000',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('RoleAssignment', () => {
|
||||
it('renders subagent directory and model role routing', async () => {
|
||||
vi.mocked(fetchProviders).mockResolvedValueOnce([
|
||||
{
|
||||
id: 'p1',
|
||||
name: 'OpenAI',
|
||||
type: 'openai_responses',
|
||||
baseUrl: null,
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
isEnabled: true,
|
||||
hasKey: true,
|
||||
extraConfig: {},
|
||||
createdAt: '2026-01-01',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchRoles).mockResolvedValueOnce([
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o',
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(fetchKernelSubagents).mockResolvedValueOnce([
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:triage',
|
||||
source: 'built-in',
|
||||
description: '根据变更范围决定 review 域与审查模式',
|
||||
whenToUse: '当需要规划任务时',
|
||||
modelRole: 'planner',
|
||||
tags: ['review', 'planner', 'triage'],
|
||||
resumable: true,
|
||||
},
|
||||
{
|
||||
kind: 'subagent',
|
||||
name: 'review:full_review',
|
||||
source: 'built-in',
|
||||
description: '执行一次完整自主代码审查',
|
||||
whenToUse: '当 triage 生成审查提示后执行完整审查',
|
||||
modelRole: 'specialist',
|
||||
tags: ['review', 'specialist', 'full-review', 'autonomous-review'],
|
||||
resumable: true,
|
||||
},
|
||||
]);
|
||||
|
||||
vi.mocked(setRole).mockResolvedValue({
|
||||
role: 'planner',
|
||||
providerId: 'p1',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'custom-planner-model',
|
||||
});
|
||||
it('renders agent model settings and saves edits', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue(makeConfigResponse());
|
||||
vi.mocked(updateConfig).mockResolvedValue(undefined);
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
expect(await screen.findByText('Subagents 与模型路由')).toBeInTheDocument();
|
||||
expect((await screen.findAllByText('review:triage')).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText('模型角色路由')).toBeInTheDocument();
|
||||
expect(screen.getByText('Planner')).toBeInTheDocument();
|
||||
expect(screen.getByText('Specialist')).toBeInTheDocument();
|
||||
// Wait for the fields to load and render
|
||||
expect(await screen.findByText('主智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByText('默认子智能体模型')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大并发调用')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 最大重试次数')).toBeInTheDocument();
|
||||
expect(screen.getByText('LLM 重试基础延迟(ms)')).toBeInTheDocument();
|
||||
|
||||
const providerPlaceholders = screen.getAllByText('选择提供商');
|
||||
const triggerButton = providerPlaceholders[0].closest('button')!;
|
||||
await user.click(triggerButton);
|
||||
await user.click(await screen.findByRole('option', { name: /OpenAI/ }));
|
||||
const legacyLabels = ['pla' + 'nner', 'speci' + 'alist', 'ju' + 'dge', '角色' + '分配'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText(['/', 'llm', '/', 'roles'].join(''))).not.toBeInTheDocument();
|
||||
|
||||
const modelInputs = screen.getAllByPlaceholderText('选择或输入模型...') as HTMLInputElement[];
|
||||
await waitFor(() => {
|
||||
expect(modelInputs[0].value).toBe('gpt-4o');
|
||||
const mainModelInput = screen.getByLabelText('主智能体模型');
|
||||
const subagentModelInput = screen.getByLabelText('默认子智能体模型');
|
||||
const maxCallsInput = screen.getByLabelText('LLM 最大并发调用');
|
||||
const retryAttemptsInput = screen.getByLabelText('LLM 最大重试次数');
|
||||
const retryDelayInput = screen.getByLabelText('LLM 重试基础延迟(ms)');
|
||||
|
||||
await user.clear(mainModelInput);
|
||||
await user.type(mainModelInput, 'claude-3-5-sonnet');
|
||||
|
||||
await user.clear(subagentModelInput);
|
||||
await user.type(subagentModelInput, 'claude-3-5-haiku');
|
||||
|
||||
await user.clear(maxCallsInput);
|
||||
await user.type(maxCallsInput, '8');
|
||||
|
||||
await user.clear(retryAttemptsInput);
|
||||
await user.type(retryAttemptsInput, '5');
|
||||
|
||||
await user.clear(retryDelayInput);
|
||||
await user.type(retryDelayInput, '2000');
|
||||
|
||||
const saveButton = screen.getByRole('button', { name: '保存设置' });
|
||||
await user.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(updateConfig).toHaveBeenCalledTimes(1));
|
||||
const payload = vi.mocked(updateConfig).mock.calls[0][0];
|
||||
expect(payload).toEqual({
|
||||
AGENT_MAIN_MODEL: 'claude-3-5-sonnet',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'claude-3-5-haiku',
|
||||
LLM_MAX_CONCURRENT_CALLS: '8',
|
||||
LLM_RETRY_MAX_ATTEMPTS: '5',
|
||||
LLM_RETRY_BASE_DELAY_MS: '2000',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders missing-field/unavailable state when fields are missing', async () => {
|
||||
vi.mocked(fetchConfig).mockResolvedValue({
|
||||
groups: [
|
||||
{
|
||||
key: 'llm',
|
||||
label: 'LLM 设置',
|
||||
description: 'LLM 运行时与弹性设置',
|
||||
icon: 'brain',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
label: '主智能体模型',
|
||||
description: '主智能体默认使用的模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
value: 'gpt-4o',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await user.clear(modelInputs[0]);
|
||||
await user.type(modelInputs[0], 'custom-planner-model');
|
||||
expect(modelInputs[0].value).toBe('custom-planner-model');
|
||||
renderWithQuery(<RoleAssignment />);
|
||||
|
||||
// Wait for the warning to load and render
|
||||
expect(await screen.findByText('部分配置项在系统中不可用:')).toBeInTheDocument();
|
||||
expect(screen.getByText('智能体模型设置')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_MAX_CONCURRENT_CALLS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_MAX_ATTEMPTS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('LLM_RETRY_BASE_DELAY_MS')).toBeInTheDocument();
|
||||
|
||||
const subagentInput = screen.getByLabelText('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(subagentInput).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Waypoints } from 'lucide-react';
|
||||
import { LogOut, Bot, FolderGit2, Sliders, Bell, Menu, X, PanelLeftClose, PanelLeftOpen, FileSearch, Sun, Moon, Palette, Layers } from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select';
|
||||
import { isColorPalette, useColorPalette } from '@/hooks/useColorPalette';
|
||||
|
||||
const navItems = [
|
||||
{ path: '/sessions', label: '审查会话', icon: Waypoints },
|
||||
{ path: '/repos', label: '仓库管理', icon: FolderGit2 },
|
||||
{ path: '/config', label: '系统配置', icon: Sliders },
|
||||
{ path: '/notifications', label: '通知管理', icon: Bell },
|
||||
{ path: '/review-config', label: '审查配置', icon: FileSearch },
|
||||
{ path: '/review-runs', label: '审查任务', icon: Layers },
|
||||
] as const;
|
||||
|
||||
export default function DashboardPage() {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
312
frontend/src/pages/__tests__/ReviewSessionsPage.test.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import ReviewSessionsPage from '../ReviewSessionsPage';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
|
||||
vi.mock('@/services/reviewSessionService', () => ({
|
||||
fetchReviewRuns: vi.fn(),
|
||||
fetchReviewRunDetails: vi.fn(),
|
||||
}));
|
||||
|
||||
function renderWithQuery(ui: ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
|
||||
}
|
||||
|
||||
describe('ReviewSessionsPage', () => {
|
||||
it('Scenario 1: renders main agent plus two subagents with statuses, tool counts, and model info', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-1',
|
||||
idempotencyKey: 'key-1',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 42,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
role: 'user',
|
||||
content: 'Hello',
|
||||
metadata: {},
|
||||
createdAt: '2026-05-25T00:00:05.000Z',
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
sessionId: 'session-main',
|
||||
sequence: 1,
|
||||
toolName: 'search_code',
|
||||
status: 'completed',
|
||||
arguments: {},
|
||||
createdAt: '2026-05-25T00:00:10.000Z',
|
||||
},
|
||||
],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-1',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-1',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-1',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-1',
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:15.000Z',
|
||||
completedAt: '2026-05-25T00:00:30.000Z',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
updatedAt: '2026-05-25T00:00:30.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'inv-2',
|
||||
parentSessionId: 'session-main',
|
||||
childSessionId: 'session-sub-2',
|
||||
sequence: 2,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
input: {},
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
childSession: {
|
||||
id: 'session-sub-2',
|
||||
parentSessionId: 'session-main',
|
||||
parentInvocationId: 'inv-2',
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:35.000Z',
|
||||
completedAt: '2026-05-25T00:00:50.000Z',
|
||||
createdAt: '2026-05-25T00:00:35.000Z',
|
||||
updatedAt: '2026-05-25T00:00:50.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const mainAgentText = await screen.findByText('主代理: review-main-agent');
|
||||
expect(mainAgentText).toBeInTheDocument();
|
||||
|
||||
expect(screen.getAllByText('gpt-main').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert subagents are rendered
|
||||
expect(screen.getByText('子代理: security-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-a').length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText('子代理: quality-reviewer')).toBeInTheDocument();
|
||||
expect(screen.getAllByText('gpt-sub-b').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Assert tool calls count is visible in the details panel tabs
|
||||
expect(screen.getByText('工具调用 (1)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 2: renders failed subagent invocation and findings correctly', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-2',
|
||||
idempotencyKey: 'key-2',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'failed' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 43,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [
|
||||
{
|
||||
id: 'finding-1',
|
||||
runId: 'run-2',
|
||||
fingerprint: 'fp-1',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/db.ts',
|
||||
line: 10,
|
||||
title: 'SQL Injection vulnerability',
|
||||
detail: 'Direct string concatenation in query',
|
||||
evidence: 'db.query("SELECT * FROM users WHERE id = " + id)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
published: false,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-2',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'failed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [
|
||||
{
|
||||
id: 'inv-failed',
|
||||
parentSessionId: 'session-main-2',
|
||||
sequence: 1,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
status: 'failed',
|
||||
input: {},
|
||||
error: 'Failed to initialize subagent',
|
||||
createdAt: '2026-05-25T00:00:15.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
// Wait for details to load and render
|
||||
const failedSubagentText = await screen.findByText('子代理启动失败: security-reviewer');
|
||||
expect(failedSubagentText).toBeInTheDocument();
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Switch to findings tab
|
||||
const findingsTab = screen.getByText('审查结果 (1)');
|
||||
expect(findingsTab).toBeInTheDocument();
|
||||
await user.click(findingsTab);
|
||||
|
||||
// Assert finding title still renders
|
||||
const findingTitle = await screen.findByText('SQL Injection vulnerability');
|
||||
expect(findingTitle).toBeInTheDocument();
|
||||
expect(screen.getByText('Direct string concatenation in query')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Scenario 3: asserts no legacy review labels are visible', async () => {
|
||||
const mockRuns = {
|
||||
data: [
|
||||
{
|
||||
id: 'run-3',
|
||||
idempotencyKey: 'key-3',
|
||||
eventType: 'pull_request' as const,
|
||||
status: 'succeeded' as const,
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
cloneUrl: 'http://clone',
|
||||
prNumber: 44,
|
||||
attempts: 1,
|
||||
maxAttempts: 2,
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockDetails = {
|
||||
run: mockRuns.data[0],
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
sessionTree: {
|
||||
id: 'session-main-3',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
metadata: {},
|
||||
startedAt: '2026-05-25T00:00:00.000Z',
|
||||
completedAt: '2026-05-25T00:01:00.000Z',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
updatedAt: '2026-05-25T00:01:00.000Z',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fetchReviewRuns).mockResolvedValue(mockRuns as any);
|
||||
vi.mocked(fetchReviewRunDetails).mockResolvedValue(mockDetails as any);
|
||||
|
||||
renderWithQuery(<ReviewSessionsPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('test-owner/test-repo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const legacyLabels = ['tri' + 'age', 'speci' + 'alist', 'ju' + 'dge', 'pla' + 'nner'];
|
||||
legacyLabels.forEach((label) => {
|
||||
expect(screen.queryByText(label)).toBeNull();
|
||||
});
|
||||
expect(screen.queryByText('分流')).toBeNull();
|
||||
expect(screen.queryByText('专家')).toBeNull();
|
||||
expect(screen.queryByText('裁判')).toBeNull();
|
||||
expect(screen.queryByText('规划')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,25 +15,6 @@ export interface ProviderDto {
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface RoleAssignmentDto {
|
||||
role: string;
|
||||
providerId: string | null;
|
||||
providerName: string | null;
|
||||
providerType: string | null;
|
||||
model: string | null;
|
||||
}
|
||||
|
||||
export interface KernelSubagentDto {
|
||||
kind: 'subagent';
|
||||
name: string;
|
||||
source: 'built-in' | 'custom' | 'plugin';
|
||||
description: string;
|
||||
whenToUse: string;
|
||||
modelRole?: string;
|
||||
tags: string[];
|
||||
resumable?: boolean;
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -86,21 +67,6 @@ export const deleteApiKey = async (id: string): Promise<void> => {
|
||||
await api.delete(`/llm/providers/${id}/key`);
|
||||
};
|
||||
|
||||
export const fetchRoles = async (): Promise<RoleAssignmentDto[]> => {
|
||||
const response = await api.get<RoleAssignmentDto[]>('/llm/roles');
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const setRole = async (role: string, providerId: string | null, model: string | null): Promise<RoleAssignmentDto> => {
|
||||
const response = await api.put<RoleAssignmentDto>(`/llm/roles/${role}`, { providerId, model });
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchKernelSubagents = async (): Promise<KernelSubagentDto[]> => {
|
||||
const response = await api.get<{ data: KernelSubagentDto[] }>('/review/kernel/subagents');
|
||||
return response.data.data;
|
||||
};
|
||||
|
||||
export const testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
@@ -1,122 +1,147 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export interface ReviewSessionSummaryRecordDto {
|
||||
session: {
|
||||
id: string;
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
scopeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRunId?: string;
|
||||
};
|
||||
summary: {
|
||||
sessionId: string;
|
||||
scopeKey: string;
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
owner?: string;
|
||||
repo?: string;
|
||||
prNumber?: number;
|
||||
headSha?: string;
|
||||
status:
|
||||
| 'queued'
|
||||
| 'planning'
|
||||
| 'executing'
|
||||
| 'awaiting_human_feedback'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'ignored';
|
||||
currentStep?: string;
|
||||
findingCount: number;
|
||||
pendingTaskCount: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
}
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export interface ReviewPlanStepDto {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
status: 'pending' | 'queued' | 'running' | 'completed' | 'failed' | 'skipped';
|
||||
progressText?: string;
|
||||
}
|
||||
|
||||
export interface ReviewTimelineEntryDto {
|
||||
export interface ReviewRun {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
idempotencyKey: string;
|
||||
eventType: 'pull_request' | 'commit_status';
|
||||
status: ReviewRunStatus;
|
||||
owner: string;
|
||||
repo: string;
|
||||
cloneUrl: string;
|
||||
headCloneUrl?: string;
|
||||
prNumber?: number;
|
||||
relatedPrNumber?: number;
|
||||
baseSha?: string;
|
||||
headSha?: string;
|
||||
commitSha?: string;
|
||||
commitMessage?: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
startedAt?: string;
|
||||
finishedAt?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ReviewStep {
|
||||
id: string;
|
||||
runId: string;
|
||||
stepName: string;
|
||||
agentName?: string;
|
||||
status: 'started' | 'succeeded' | 'failed';
|
||||
startedAt: string;
|
||||
finishedAt?: string;
|
||||
latencyMs?: number;
|
||||
inputRef?: string;
|
||||
outputRef?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Finding {
|
||||
id: string;
|
||||
runId: string;
|
||||
fingerprint: string;
|
||||
category: 'correctness' | 'security' | 'reliability' | 'maintainability';
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
confidence: number;
|
||||
path: string;
|
||||
line: number;
|
||||
title: string;
|
||||
detail: string;
|
||||
tone: 'neutral' | 'success' | 'warning' | 'danger';
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewSessionDetailDto {
|
||||
session: ReviewSessionSummaryRecordDto['session'];
|
||||
summary: ReviewSessionSummaryRecordDto['summary'];
|
||||
checkpoint: {
|
||||
state: Record<string, unknown>;
|
||||
pendingTasks: Array<{ kind: 'skill' | 'subagent'; name: string; input?: Record<string, unknown> }>;
|
||||
stopReason?: string;
|
||||
} | null;
|
||||
plan: ReviewPlanStepDto[];
|
||||
timeline: ReviewTimelineEntryDto[];
|
||||
events: Array<{
|
||||
id: string;
|
||||
sessionId: string;
|
||||
eventType: string;
|
||||
payload: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}>;
|
||||
runDetails: {
|
||||
run: {
|
||||
id: string;
|
||||
eventType: string;
|
||||
status: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
prNumber?: number;
|
||||
commitSha?: string;
|
||||
headSha?: string;
|
||||
baseSha?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
findings: Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
severity: 'high' | 'medium' | 'low';
|
||||
category: string;
|
||||
path: string;
|
||||
line: number;
|
||||
confidence: number;
|
||||
published: boolean;
|
||||
fingerprint: string;
|
||||
}>;
|
||||
comments: Array<{
|
||||
id: string;
|
||||
status: string;
|
||||
body: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
createdAt: string;
|
||||
}>;
|
||||
} | null;
|
||||
export interface ReviewCommentRecord {
|
||||
id: string;
|
||||
runId: string;
|
||||
path?: string;
|
||||
line?: number;
|
||||
body: string;
|
||||
giteaCommentId?: number;
|
||||
status: 'pending' | 'published' | 'failed';
|
||||
createdAt: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
export interface ReviewSessionListResponse {
|
||||
data: ReviewSessionSummaryRecordDto[];
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: any;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export const fetchReviewSessions = async (): Promise<ReviewSessionSummaryRecordDto[]> => {
|
||||
const response = await api.get<ReviewSessionListResponse>('/review/sessions');
|
||||
return response.data.data;
|
||||
};
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
arguments: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export const fetchReviewSessionDetail = async (
|
||||
sessionId: string
|
||||
): Promise<ReviewSessionDetailDto> => {
|
||||
const response = await api.get<ReviewSessionDetailDto>(`/review/sessions/${sessionId}`);
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
input: any;
|
||||
result?: any;
|
||||
error?: any;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
metadata: Record<string, any>;
|
||||
finalResult?: any;
|
||||
error?: any;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface ReviewRunDetails {
|
||||
run: ReviewRun;
|
||||
steps: ReviewStep[];
|
||||
findings: Finding[];
|
||||
comments: ReviewCommentRecord[];
|
||||
sessionTree?: AgentSessionTree | null;
|
||||
}
|
||||
|
||||
export const fetchReviewRuns = async (limit: number = 50): Promise<{ data: ReviewRun[] }> => {
|
||||
const response = await api.get<{ data: ReviewRun[] }>('/review/runs', {
|
||||
params: { limit },
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
export const fetchReviewRunDetails = async (runId: string): Promise<ReviewRunDetails> => {
|
||||
const response = await api.get<ReviewRunDetails>(`/review/runs/${runId}`);
|
||||
return response.data;
|
||||
};
|
||||
|
||||
@@ -107,9 +107,9 @@ const configResponse = {
|
||||
label: '审查引擎',
|
||||
description: '当前使用的审查引擎',
|
||||
type: 'enum',
|
||||
enumValues: ['kernel', 'codex'],
|
||||
enumValues: ['agent', 'codex'],
|
||||
sensitive: false,
|
||||
value: 'kernel',
|
||||
value: 'agent',
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
@@ -195,24 +195,6 @@ const configResponse = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆设置',
|
||||
description: '控制上下文记忆与保留策略。',
|
||||
icon: 'database',
|
||||
fields: [
|
||||
{
|
||||
envKey: 'MEMORY_ENABLED',
|
||||
label: '启用记忆',
|
||||
description: '是否启用长期记忆',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
value: true,
|
||||
hasValue: true,
|
||||
source: 'db',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -243,23 +225,6 @@ const providers = [
|
||||
},
|
||||
];
|
||||
|
||||
const roles = [
|
||||
{
|
||||
role: 'planner',
|
||||
providerId: 'provider-openai',
|
||||
providerName: 'OpenAI',
|
||||
providerType: 'openai_responses',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
{
|
||||
role: 'specialist',
|
||||
providerId: 'provider-deepseek',
|
||||
providerName: 'DeepSeek',
|
||||
providerType: 'openai_compatible',
|
||||
model: 'deepseek-chat',
|
||||
},
|
||||
];
|
||||
|
||||
const modelSuggestions = {
|
||||
openai_compatible: ['deepseek-chat', 'gpt-4o-mini'],
|
||||
openai_responses: ['gpt-4o', 'gpt-4o-mini', 'o3-mini'],
|
||||
@@ -375,14 +340,6 @@ export async function installVisualApiMocks(page: Page) {
|
||||
return route.fulfill({ status: 204, body: '' });
|
||||
}
|
||||
|
||||
if (method === 'GET' && path.endsWith('/admin/api/llm/roles')) {
|
||||
return json(route, roles);
|
||||
}
|
||||
|
||||
if (method === 'PUT' && /\/admin\/api\/llm\/roles\/[^/]+$/.test(path)) {
|
||||
return json(route, roles[0]);
|
||||
}
|
||||
|
||||
if (method === 'POST' && /\/admin\/api\/llm\/providers\/[^/]+\/test$/.test(path)) {
|
||||
return json(route, {
|
||||
success: true,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
---
|
||||
# ConfigMap: only infrastructure-level env vars that must be known before DB init
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
@@ -11,9 +9,6 @@ metadata:
|
||||
data:
|
||||
PORT: "5174"
|
||||
LOG_LEVEL: "error"
|
||||
# All settings (Gitea connection, webhook secret, admin password, review engine,
|
||||
# Feishu, memory, etc.) are managed through the Admin Dashboard Web UI.
|
||||
# They are auto-seeded with secure defaults on first boot.
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
|
||||
@@ -6,5 +6,4 @@ namespace: gitea-assistant
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- secret.yaml
|
||||
- qdrant.yaml
|
||||
- gitea-assistant.yaml
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
containers:
|
||||
- name: qdrant
|
||||
image: qdrant/qdrant:latest
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 6333
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
containerPort: 6334
|
||||
protocol: TCP
|
||||
resources:
|
||||
limits:
|
||||
memory: "1Gi"
|
||||
requests:
|
||||
memory: "512Mi"
|
||||
cpu: "250m"
|
||||
volumeMounts:
|
||||
- name: qdrant-storage
|
||||
mountPath: /qdrant/storage
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 30
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
volumes:
|
||||
- name: qdrant-storage
|
||||
hostPath:
|
||||
# Customize this path to match your node's storage layout
|
||||
path: /opt/gitea-assistant/qdrant
|
||||
type: DirectoryOrCreate
|
||||
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: qdrant
|
||||
namespace: gitea-assistant
|
||||
labels:
|
||||
app.kubernetes.io/name: qdrant
|
||||
app.kubernetes.io/part-of: gitea-assistant
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: qdrant
|
||||
ports:
|
||||
- name: http
|
||||
port: 6333
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
- name: grpc
|
||||
port: 6334
|
||||
targetPort: grpc
|
||||
protocol: TCP
|
||||
@@ -9,7 +9,6 @@
|
||||
"@anthropic-ai/sdk": "^0.78.0",
|
||||
"@google/genai": "^1.43.0",
|
||||
"@hono/zod-validator": "^0.4.3",
|
||||
"@qdrant/js-client-rest": "^1.16.2",
|
||||
"axios": "^1.8.3",
|
||||
"dotenv": "^16.4.7",
|
||||
"hono": "^4.11.9",
|
||||
@@ -52,7 +51,7 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"test:e2e": "E2E_MOCK_LLM=1 bun test ./e2e/__tests__/e2e-review.test.ts",
|
||||
"test:e2e": "bash ./e2e/test.sh",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
|
||||
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
|
||||
import { AgentKernelRunner } from '../runtime/agent-kernel-runner';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
interface DummyState {
|
||||
counter: number;
|
||||
}
|
||||
|
||||
describe('AgentKernelRunner', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-runner-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('runs queued skills and subagents and persists checkpoint', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#7',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 7 },
|
||||
runId: 'run-7',
|
||||
});
|
||||
|
||||
const skillRegistry = new KernelTaskRegistry<DummyState>();
|
||||
const subagentRegistry = new KernelAgentRegistry<DummyState>();
|
||||
|
||||
skillRegistry.register({
|
||||
kind: 'skill',
|
||||
name: 'step_one',
|
||||
description: 'Initial skill for runner test',
|
||||
execute: async () => ({
|
||||
state: { counter: 1 },
|
||||
enqueue: [{ kind: 'subagent', name: 'step_two' }],
|
||||
}),
|
||||
});
|
||||
|
||||
subagentRegistry.register({
|
||||
kind: 'subagent',
|
||||
name: 'step_two',
|
||||
source: 'built-in',
|
||||
whenToUse: 'Increment the test counter',
|
||||
description: 'Test subagent used by runner tests',
|
||||
execute: async (_task, context) => ({
|
||||
state: { counter: context.state.counter + 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
|
||||
plan: () => [],
|
||||
});
|
||||
const checkpoint = await runner.run({
|
||||
sessionId: session.id,
|
||||
runId: 'run-7',
|
||||
initialState: { counter: 0 },
|
||||
initialTasks: [{ kind: 'skill', name: 'step_one' }],
|
||||
});
|
||||
|
||||
const events = kernelSessionRepository.listEvents(session.id);
|
||||
|
||||
expect(checkpoint.state.counter).toBe(2);
|
||||
expect(checkpoint.pendingTasks).toHaveLength(0);
|
||||
expect(checkpoint.stopReason).toBe('completed');
|
||||
expect(events.map((event) => event.eventType).sort()).toEqual([
|
||||
'task_completed',
|
||||
'task_completed',
|
||||
'task_started',
|
||||
'task_started',
|
||||
]);
|
||||
});
|
||||
|
||||
test('continueExisting ignores persisted stop reason and resumes planned work', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#8',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 8 },
|
||||
runId: 'run-8',
|
||||
});
|
||||
|
||||
kernelSessionRepository.saveCheckpoint(session.id, {
|
||||
state: { counter: 1 },
|
||||
pendingTasks: [],
|
||||
stopReason: 'awaiting_human_feedback',
|
||||
});
|
||||
|
||||
const skillRegistry = new KernelTaskRegistry<DummyState>();
|
||||
const subagentRegistry = new KernelAgentRegistry<DummyState>();
|
||||
|
||||
skillRegistry.register({
|
||||
kind: 'skill',
|
||||
name: 'resume_step',
|
||||
description: 'Resume skill for runner test',
|
||||
execute: async (_task, context) => ({
|
||||
state: { counter: context.state.counter + 1 },
|
||||
}),
|
||||
});
|
||||
|
||||
const runner = new AgentKernelRunner(skillRegistry, new KernelAgentInvoker(subagentRegistry), {
|
||||
plan: (context) =>
|
||||
context.state.counter < 2 ? [{ kind: 'skill', name: 'resume_step' }] : [],
|
||||
});
|
||||
|
||||
const checkpoint = await runner.run({
|
||||
sessionId: session.id,
|
||||
runId: 'run-8',
|
||||
initialState: { counter: 0 },
|
||||
initialTasks: [],
|
||||
continueExisting: true,
|
||||
});
|
||||
|
||||
expect(checkpoint.state.counter).toBe(2);
|
||||
expect(checkpoint.stopReason).toBe('completed');
|
||||
});
|
||||
});
|
||||
@@ -1,81 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { getKernelAgentContext } from '../agents/kernel-agent-context';
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelAgentRegistry } from '../agents/kernel-agent-registry';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
interface DummyState {
|
||||
value: number;
|
||||
}
|
||||
|
||||
describe('KernelAgentInvoker', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-agent-invoker-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('invokes subagent with isolated agent context and structured result', async () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#88',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 88 },
|
||||
runId: 'run-88',
|
||||
});
|
||||
|
||||
const registry = new KernelAgentRegistry<DummyState>();
|
||||
registry.register({
|
||||
kind: 'subagent',
|
||||
name: 'test:subagent',
|
||||
source: 'built-in',
|
||||
description: 'Test subagent',
|
||||
whenToUse: 'Used by invoker test',
|
||||
tags: ['test'],
|
||||
execute: async (_task, context) => {
|
||||
const agentContext = getKernelAgentContext();
|
||||
expect(agentContext?.agentType).toBe('subagent');
|
||||
expect(agentContext?.subagentName).toBe('test:subagent');
|
||||
expect(context.delegation.parentSessionId).toBe(session.id);
|
||||
|
||||
return {
|
||||
state: { value: context.state.value + 1 },
|
||||
summary: 'subagent completed',
|
||||
artifacts: { nextValue: context.state.value + 1 },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const invoker = new KernelAgentInvoker(registry);
|
||||
const output = await invoker.invoke(
|
||||
{ kind: 'subagent', name: 'test:subagent', input: { focus: 'test' } },
|
||||
{
|
||||
session,
|
||||
runId: 'run-88',
|
||||
state: { value: 1 },
|
||||
}
|
||||
);
|
||||
|
||||
expect(output.result?.state).toEqual({ value: 2 });
|
||||
expect(output.invocation.status).toBe('completed');
|
||||
expect(output.invocation.result?.summary).toBe('subagent completed');
|
||||
expect(output.invocation.result?.artifacts).toEqual({ nextValue: 2 });
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
describe('KernelSessionRepository', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-session-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('ensureSession reuses the same scope key and updates metadata', () => {
|
||||
const first = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#42',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 42 },
|
||||
runId: 'run-1',
|
||||
});
|
||||
|
||||
const second = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#42',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 42, updated: true },
|
||||
runId: 'run-2',
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
expect(second.lastRunId).toBe('run-2');
|
||||
expect(second.metadata).toEqual({ owner: 'acme', repo: 'repo', prNumber: 42, updated: true });
|
||||
});
|
||||
|
||||
test('appendEvent and saveCheckpoint persist session runtime state', () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#99',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 99 },
|
||||
runId: 'run-99',
|
||||
});
|
||||
|
||||
kernelSessionRepository.appendEvent(session.id, 'review_enqueued', { runId: 'run-99' });
|
||||
kernelSessionRepository.appendEvent(session.id, 'task_started', { name: 'prepare_workspace' });
|
||||
kernelSessionRepository.saveCheckpoint(session.id, {
|
||||
state: { prepared: true, findings: 3 },
|
||||
pendingTasks: [{ kind: 'skill', name: 'publish_review' }],
|
||||
stopReason: 'waiting',
|
||||
});
|
||||
|
||||
const events = kernelSessionRepository.listEvents(session.id);
|
||||
const checkpoint = kernelSessionRepository.loadCheckpoint<{
|
||||
prepared: boolean;
|
||||
findings: number;
|
||||
}>(session.id);
|
||||
|
||||
expect(events).toHaveLength(2);
|
||||
expect(events.map((event) => event.eventType).sort()).toEqual([
|
||||
'review_enqueued',
|
||||
'task_started',
|
||||
]);
|
||||
expect(checkpoint).not.toBeNull();
|
||||
expect(checkpoint?.state).toEqual({ prepared: true, findings: 3 });
|
||||
expect(checkpoint?.pendingTasks).toEqual([{ kind: 'skill', name: 'publish_review' }]);
|
||||
expect(checkpoint?.stopReason).toBe('waiting');
|
||||
});
|
||||
|
||||
test('can query sessions by scope key and list sessions', () => {
|
||||
const first = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#1',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 1 },
|
||||
runId: 'run-1',
|
||||
});
|
||||
const second = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#2',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 2 },
|
||||
runId: 'run-2',
|
||||
});
|
||||
|
||||
expect(kernelSessionRepository.getSessionByScopeKey('acme/repo#1')?.id).toBe(first.id);
|
||||
expect(kernelSessionRepository.listSessions(10).map((session) => session.id)).toEqual(
|
||||
expect.arrayContaining([first.id, second.id])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
|
||||
describe('KernelSessionRepository subagent invocations', () => {
|
||||
let tempDir: string;
|
||||
let savedDbPath: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'kernel-subagent-db-'));
|
||||
savedDbPath = process.env.DATABASE_PATH;
|
||||
process.env.DATABASE_PATH = path.join(tempDir, 'assistant.db');
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('persists and lists subagent invocations', () => {
|
||||
const session = kernelSessionRepository.ensureSession({
|
||||
scopeType: 'pull_request',
|
||||
scopeKey: 'acme/repo#101',
|
||||
metadata: { owner: 'acme', repo: 'repo', prNumber: 101 },
|
||||
runId: 'run-101',
|
||||
});
|
||||
|
||||
const invocation = kernelSessionRepository.createSubagentInvocation({
|
||||
parentSessionId: session.id,
|
||||
parentRunId: 'run-101',
|
||||
parentTaskName: 'custom:security-audit',
|
||||
subagentName: 'custom:security-audit',
|
||||
agentId: 'agent-123',
|
||||
packet: {
|
||||
goal: 'Review security issues',
|
||||
parentTaskName: 'custom:security-audit',
|
||||
input: { domain: 'security' },
|
||||
parentSessionId: session.id,
|
||||
parentRunId: 'run-101',
|
||||
contextSummary: 'summary',
|
||||
},
|
||||
});
|
||||
|
||||
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'completed', {
|
||||
agentId: 'agent-123',
|
||||
agentType: 'custom:security-audit',
|
||||
summary: 'security review done',
|
||||
totalDurationMs: 10,
|
||||
totalToolUseCount: 0,
|
||||
totalTokens: 0,
|
||||
artifacts: { findings: 2 },
|
||||
});
|
||||
|
||||
const invocations = kernelSessionRepository.listSubagentInvocations(session.id);
|
||||
expect(invocations).toHaveLength(1);
|
||||
expect(invocations[0]?.subagentName).toBe('custom:security-audit');
|
||||
expect(invocations[0]?.result?.summary).toBe('security review done');
|
||||
});
|
||||
});
|
||||
@@ -1,15 +0,0 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { KernelSubagentContextRecord } from '../types';
|
||||
|
||||
const kernelAgentContextStorage = new AsyncLocalStorage<KernelSubagentContextRecord>();
|
||||
|
||||
export function getKernelAgentContext(): KernelSubagentContextRecord | undefined {
|
||||
return kernelAgentContextStorage.getStore();
|
||||
}
|
||||
|
||||
export function runWithKernelAgentContext<T>(
|
||||
context: KernelSubagentContextRecord,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
return kernelAgentContextStorage.run(context, fn);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { KernelHookRegistry } from '../hooks/kernel-hook-registry';
|
||||
import { runKernelHooks } from '../hooks/kernel-hook-runner';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
KernelAgentExecutionContext,
|
||||
KernelDelegationPacket,
|
||||
KernelExecutionContext,
|
||||
KernelHandlerResult,
|
||||
KernelSubagentDefinition,
|
||||
KernelTask,
|
||||
} from '../types';
|
||||
import { runWithKernelAgentContext } from './kernel-agent-context';
|
||||
import { KernelAgentRegistry } from './kernel-agent-registry';
|
||||
import { finalizeKernelSubagentResult } from './kernel-subagent-result';
|
||||
|
||||
export interface KernelSubagentInvocationOutput<TState> {
|
||||
result?: KernelHandlerResult<TState>;
|
||||
invocation: ReturnType<typeof kernelSessionRepository.listSubagentInvocations>[number];
|
||||
}
|
||||
|
||||
export class KernelAgentInvoker<TState> {
|
||||
constructor(
|
||||
private readonly registry: KernelAgentRegistry<TState>,
|
||||
private readonly hookRegistry?: KernelHookRegistry
|
||||
) {}
|
||||
|
||||
get(name: string): KernelSubagentDefinition<TState> | undefined {
|
||||
return this.registry.get(name);
|
||||
}
|
||||
|
||||
getAll(): KernelSubagentDefinition<TState>[] {
|
||||
return this.registry.getAll();
|
||||
}
|
||||
|
||||
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
|
||||
return this.registry.filterByTag(tag);
|
||||
}
|
||||
|
||||
async invoke(
|
||||
task: KernelTask,
|
||||
context: KernelExecutionContext<TState>
|
||||
): Promise<KernelSubagentInvocationOutput<TState>> {
|
||||
const agent = this.registry.get(task.name);
|
||||
if (!agent) {
|
||||
throw new Error(`Kernel subagent definition not found: ${task.name}`);
|
||||
}
|
||||
|
||||
const agentId = randomUUID();
|
||||
const delegation: KernelDelegationPacket = {
|
||||
goal: agent.whenToUse,
|
||||
parentTaskName: task.name,
|
||||
input: task.input ?? {},
|
||||
parentSessionId: context.session.id,
|
||||
parentRunId: context.runId,
|
||||
contextSummary:
|
||||
typeof (context.state as { compressedContext?: { summary?: string } }).compressedContext
|
||||
?.summary === 'string'
|
||||
? (context.state as { compressedContext?: { summary?: string } }).compressedContext
|
||||
?.summary
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const invocation = kernelSessionRepository.createSubagentInvocation({
|
||||
parentSessionId: context.session.id,
|
||||
parentRunId: context.runId,
|
||||
parentTaskName: task.name,
|
||||
subagentName: agent.name,
|
||||
agentId,
|
||||
packet: delegation,
|
||||
});
|
||||
|
||||
const agentContext: KernelAgentExecutionContext<TState> = {
|
||||
...context,
|
||||
agent,
|
||||
delegation,
|
||||
};
|
||||
|
||||
if (this.hookRegistry) {
|
||||
const hookResult = await runKernelHooks({
|
||||
registry: this.hookRegistry,
|
||||
input: {
|
||||
event: 'SubagentStart',
|
||||
sessionId: context.session.id,
|
||||
runId: context.runId,
|
||||
subagentName: agent.name,
|
||||
agentId,
|
||||
packet: delegation,
|
||||
},
|
||||
});
|
||||
if (hookResult.blockingReason) {
|
||||
throw new Error(hookResult.blockingReason);
|
||||
}
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
const result = await runWithKernelAgentContext(
|
||||
{
|
||||
agentId,
|
||||
parentSessionId: context.session.id,
|
||||
agentType: 'subagent',
|
||||
subagentName: agent.name,
|
||||
source: agent.source,
|
||||
invocationKind: 'spawn',
|
||||
},
|
||||
() => agent.execute(task, agentContext)
|
||||
);
|
||||
|
||||
const finalized = finalizeKernelSubagentResult({
|
||||
agentId,
|
||||
agentType: agent.name,
|
||||
startTime,
|
||||
result,
|
||||
});
|
||||
|
||||
return {
|
||||
result,
|
||||
invocation: kernelSessionRepository.completeSubagentInvocation(
|
||||
invocation.id,
|
||||
'completed',
|
||||
finalized
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
const finalized = finalizeKernelSubagentResult({
|
||||
agentId,
|
||||
agentType: agent.name,
|
||||
startTime,
|
||||
result: {
|
||||
summary: error instanceof Error ? error.message : String(error),
|
||||
artifacts: { error: error instanceof Error ? error.message : String(error) },
|
||||
},
|
||||
});
|
||||
|
||||
kernelSessionRepository.completeSubagentInvocation(invocation.id, 'failed', finalized);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { KernelSubagentDefinition } from '../types';
|
||||
|
||||
export class KernelAgentRegistry<TState> {
|
||||
private readonly agents = new Map<string, KernelSubagentDefinition<TState>>();
|
||||
|
||||
register(agent: KernelSubagentDefinition<TState>): void {
|
||||
this.agents.set(agent.name, agent);
|
||||
}
|
||||
|
||||
get(agentType: string): KernelSubagentDefinition<TState> | undefined {
|
||||
return this.agents.get(agentType);
|
||||
}
|
||||
|
||||
getAll(): KernelSubagentDefinition<TState>[] {
|
||||
return [...this.agents.values()];
|
||||
}
|
||||
|
||||
filterByTag(tag: string): KernelSubagentDefinition<TState>[] {
|
||||
return this.getAll().filter((agent) => agent.tags?.includes(tag));
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { KernelHandlerResult, KernelSubagentInvocationResult } from '../types';
|
||||
|
||||
export function finalizeKernelSubagentResult<TState>(params: {
|
||||
agentId: string;
|
||||
agentType: string;
|
||||
startTime: number;
|
||||
result?: KernelHandlerResult<TState>;
|
||||
}): KernelSubagentInvocationResult {
|
||||
const { agentId, agentType, startTime, result } = params;
|
||||
const totalDurationMs = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
agentId,
|
||||
agentType,
|
||||
summary: result?.summary ?? `${agentType} completed`,
|
||||
totalDurationMs,
|
||||
totalToolUseCount: 0,
|
||||
totalTokens: 0,
|
||||
artifacts: result?.artifacts,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
} from '../agent-definition';
|
||||
|
||||
describe('agentDefinitionSchema', () => {
|
||||
test('parses a valid agent definition', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: () => 'system prompt',
|
||||
});
|
||||
|
||||
expect(definition).toEqual({
|
||||
agentType: 'subagent',
|
||||
name: 'review:fix-validator',
|
||||
whenToUse: 'Use for focused fix validation after a failing test run.',
|
||||
source: 'built-in',
|
||||
tools: ['readFile', 'searchCode'],
|
||||
disallowedTools: ['deleteFile'],
|
||||
skills: ['diagnostics'],
|
||||
hooks: {
|
||||
preToolUse: { enabled: true },
|
||||
},
|
||||
model: 'gpt-4.1-mini',
|
||||
maxTurns: 3,
|
||||
permissionMode: 'ask',
|
||||
background: true,
|
||||
isolation: 'workspace',
|
||||
getSystemPrompt: definition.getSystemPrompt,
|
||||
});
|
||||
expect(isAgentDefinition(definition)).toBe(true);
|
||||
});
|
||||
|
||||
test('normalizes defaults for omitted runtime fields', () => {
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:intake',
|
||||
whenToUse: 'Use for initial task routing.',
|
||||
source: 'project',
|
||||
});
|
||||
|
||||
expect(definition.tools).toEqual([]);
|
||||
expect(definition.disallowedTools).toEqual([]);
|
||||
expect(definition.skills).toEqual([]);
|
||||
expect(definition.hooks).toEqual({});
|
||||
expect(definition.model).toBeUndefined();
|
||||
expect(definition.maxTurns).toBe(1);
|
||||
expect(definition.permissionMode).toBe('default');
|
||||
expect(definition.background).toBe(false);
|
||||
expect(definition.isolation).toBe('none');
|
||||
});
|
||||
|
||||
test('rejects missing required fields', () => {
|
||||
const result = agentDefinitionSchema.safeParse({
|
||||
agentType: 'subagent',
|
||||
source: 'built-in',
|
||||
model: 'gpt-4.1-mini',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test('strips legacy business role fields', () => {
|
||||
const legacyKeys = ['plan' + 'ner', 'special' + 'ist', 'ju' + 'dge'];
|
||||
const definition = normalizeAgentDefinition({
|
||||
agentType: 'subagent',
|
||||
name: 'review:modern',
|
||||
whenToUse: 'Use for modern runtime routing only.',
|
||||
source: 'user',
|
||||
model: 'gpt-4.1-mini',
|
||||
[legacyKeys[0]]: true,
|
||||
[legacyKeys[1]]: true,
|
||||
[legacyKeys[2]]: true,
|
||||
} as Record<string, unknown>);
|
||||
|
||||
for (const legacyKey of legacyKeys) {
|
||||
expect(legacyKey in definition).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
186
src/agent-kernel/definitions/__tests__/agent-registry.test.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
createAgentRegistry,
|
||||
loadAgentRegistry,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from '..';
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
function definition(source: 'built-in' | 'plugin' | 'user' | 'project', name: string) {
|
||||
return {
|
||||
agentType: 'reviewer',
|
||||
name,
|
||||
whenToUse: `Use ${name}`,
|
||||
source,
|
||||
model: `${name}-model`,
|
||||
};
|
||||
}
|
||||
|
||||
async function makeProjectRoot(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'agent-registry-test-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
test('keeps all agents and resolves duplicates by built-in < plugin < user < project precedence', () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [definition('built-in', 'built-in-reviewer')],
|
||||
plugin: [definition('plugin', 'plugin-reviewer')],
|
||||
user: [definition('user', 'user-reviewer')],
|
||||
project: [definition('project', 'project-reviewer')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents.map((agent) => agent.name)).toEqual([
|
||||
'built-in-reviewer',
|
||||
'plugin-reviewer',
|
||||
'user-reviewer',
|
||||
'project-reviewer',
|
||||
]);
|
||||
expect(registry.activeAgents).toHaveLength(1);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('project-reviewer');
|
||||
expect(registry.getActiveAgent('reviewer')?.source).toBe('project');
|
||||
});
|
||||
|
||||
test('loads project definitions only from .gitea-assistant/agents/*.md', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const validDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
const ignoredDir = join(projectRoot, 'agents');
|
||||
await mkdir(validDir, { recursive: true });
|
||||
await mkdir(ignoredDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(validDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Project Reviewer',
|
||||
'whenToUse: Use for project-specific review.',
|
||||
'tools: [readFile, searchCode]',
|
||||
'maxTurns: 2',
|
||||
'background: true',
|
||||
'---',
|
||||
'You are the project reviewer.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(ignoredDir, 'ignored.md'),
|
||||
['---', 'agentType: ignored', 'name: Ignored', 'whenToUse: Never.', '---', 'Ignored.'].join(
|
||||
'\n'
|
||||
),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.failedFiles).toEqual([]);
|
||||
expect(loaded.definitions).toHaveLength(1);
|
||||
expect(loaded.definitions[0].agentType).toBe('reviewer');
|
||||
expect(loaded.definitions[0].source).toBe('project');
|
||||
expect(loaded.definitions[0].tools).toEqual(['readFile', 'searchCode']);
|
||||
expect(loaded.definitions[0].maxTurns).toBe(2);
|
||||
expect(loaded.definitions[0].background).toBe(true);
|
||||
expect(loaded.definitions[0].getSystemPrompt?.()).toBe('You are the project reviewer.');
|
||||
});
|
||||
|
||||
test('keeps optional model definitions valid through markdown loading', async () => {
|
||||
const parsed = parseAgentDefinitionMarkdown(
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: No Model Reviewer',
|
||||
'whenToUse: Use without model.',
|
||||
'---',
|
||||
'Prompt body.',
|
||||
].join('\n'),
|
||||
{ source: 'project', filePath: '/tmp/reviewer.md' }
|
||||
);
|
||||
|
||||
expect('code' in parsed).toBe(false);
|
||||
if ('code' in parsed) {
|
||||
throw new Error('expected valid definition');
|
||||
}
|
||||
expect(parsed.model).toBeUndefined();
|
||||
expect(parsed.getSystemPrompt?.()).toBe('Prompt body.');
|
||||
});
|
||||
|
||||
test('returns structured load errors for malformed frontmatter and empty body', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'bad-frontmatter.md'),
|
||||
'---\nagentType [reviewer]\n---\nPrompt.',
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'empty-body.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Empty Body',
|
||||
'whenToUse: Use never.',
|
||||
'---',
|
||||
' ',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(
|
||||
join(agentsDir, 'invalid-definition.md'),
|
||||
['---', 'agentType: reviewer', 'name: Missing Use', '---', 'Prompt.'].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const loaded = await loadProjectAgentDefinitions(projectRoot);
|
||||
|
||||
expect(loaded.definitions).toEqual([]);
|
||||
expect(loaded.failedFiles.map((error) => error.code).sort()).toEqual([
|
||||
'empty_body',
|
||||
'invalid_definition',
|
||||
'malformed_frontmatter',
|
||||
]);
|
||||
expect(loaded.failedFiles.every((error) => error.source === 'project')).toBe(true);
|
||||
expect(
|
||||
loaded.failedFiles.find((error) => error.code === 'invalid_definition')?.issues?.length
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loadAgentRegistry combines built-in, plugin, user, and loaded project definitions', async () => {
|
||||
const projectRoot = await makeProjectRoot();
|
||||
const agentsDir = join(projectRoot, '.gitea-assistant', 'agents');
|
||||
await mkdir(agentsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(agentsDir, 'reviewer.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: reviewer',
|
||||
'name: Loaded Project',
|
||||
'whenToUse: Use loaded project.',
|
||||
'---',
|
||||
'Project prompt.',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const registry = await loadAgentRegistry({
|
||||
projectRoot,
|
||||
builtIn: [definition('built-in', 'Built In')],
|
||||
plugin: [definition('plugin', 'Plugin')],
|
||||
user: [definition('user', 'User')],
|
||||
});
|
||||
|
||||
expect(registry.allAgents).toHaveLength(4);
|
||||
expect(registry.failedFiles).toEqual([]);
|
||||
expect(registry.getActiveAgent('reviewer')?.name).toBe('Loaded Project');
|
||||
expect(registry.getActiveAgent('reviewer')?.getSystemPrompt?.()).toBe('Project prompt.');
|
||||
});
|
||||
});
|
||||
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
84
src/agent-kernel/definitions/agent-definition.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export type AgentDefinitionSource = 'built-in' | 'project' | 'user' | 'plugin';
|
||||
|
||||
export const AGENT_DEFINITION_SOURCES = [
|
||||
'built-in',
|
||||
'project',
|
||||
'user',
|
||||
'plugin',
|
||||
] as const satisfies readonly AgentDefinitionSource[];
|
||||
|
||||
export type AgentPermissionMode = 'default' | 'ask' | 'deny';
|
||||
|
||||
export const AGENT_PERMISSION_MODES = [
|
||||
'default',
|
||||
'ask',
|
||||
'deny',
|
||||
] as const satisfies readonly AgentPermissionMode[];
|
||||
|
||||
export type AgentIsolation = 'none' | 'workspace' | 'process';
|
||||
|
||||
export const AGENT_ISOLATIONS = [
|
||||
'none',
|
||||
'workspace',
|
||||
'process',
|
||||
] as const satisfies readonly AgentIsolation[];
|
||||
|
||||
export interface AgentDefinitionHooks {
|
||||
sessionStart?: unknown;
|
||||
subagentStart?: unknown;
|
||||
permissionRequest?: unknown;
|
||||
preToolUse?: unknown;
|
||||
postToolUse?: unknown;
|
||||
postToolUseFailure?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
const agentDefinitionHooksSchema: z.ZodType<AgentDefinitionHooks> = z
|
||||
.object({
|
||||
sessionStart: z.unknown().optional(),
|
||||
subagentStart: z.unknown().optional(),
|
||||
permissionRequest: z.unknown().optional(),
|
||||
preToolUse: z.unknown().optional(),
|
||||
postToolUse: z.unknown().optional(),
|
||||
postToolUseFailure: z.unknown().optional(),
|
||||
})
|
||||
.catchall(z.unknown());
|
||||
|
||||
export const agentDefinitionSchema = z
|
||||
.object({
|
||||
agentType: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
whenToUse: z.string().min(1),
|
||||
source: z.enum(AGENT_DEFINITION_SOURCES),
|
||||
tools: z.array(z.string()).default([]),
|
||||
disallowedTools: z.array(z.string()).default([]),
|
||||
skills: z.array(z.string()).default([]),
|
||||
hooks: agentDefinitionHooksSchema.default({}),
|
||||
model: z.string().min(1).optional(),
|
||||
maxTurns: z.number().int().positive().default(1),
|
||||
permissionMode: z.enum(AGENT_PERMISSION_MODES).default('default'),
|
||||
background: z.boolean().default(false),
|
||||
isolation: z.enum(AGENT_ISOLATIONS).default('none'),
|
||||
getSystemPrompt: z
|
||||
.custom<() => string>((value) => typeof value === 'function', {
|
||||
message: 'getSystemPrompt must be a function',
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.strip();
|
||||
|
||||
export type AgentDefinition = z.infer<typeof agentDefinitionSchema>;
|
||||
|
||||
export function normalizeAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return agentDefinitionSchema.parse(definition);
|
||||
}
|
||||
|
||||
export function isAgentDefinition(definition: unknown): definition is AgentDefinition {
|
||||
return agentDefinitionSchema.safeParse(definition).success;
|
||||
}
|
||||
|
||||
export function parseAgentDefinition(definition: unknown): AgentDefinition {
|
||||
return normalizeAgentDefinition(definition);
|
||||
}
|
||||
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
258
src/agent-kernel/definitions/agent-loader.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { ZodError } from 'zod';
|
||||
import type { AgentDefinition, AgentDefinitionSource } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
|
||||
export const PROJECT_AGENT_DEFINITIONS_DIR = '.gitea-assistant/agents';
|
||||
|
||||
export type AgentDefinitionLoadErrorCode =
|
||||
| 'missing_frontmatter'
|
||||
| 'malformed_frontmatter'
|
||||
| 'empty_body'
|
||||
| 'invalid_definition'
|
||||
| 'read_error';
|
||||
|
||||
export interface AgentDefinitionLoadError {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadErrorCode;
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
export interface AgentDefinitionLoadResult {
|
||||
definitions: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
interface MarkdownParseOptions {
|
||||
source: AgentDefinitionSource;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
type FrontmatterRecord = Record<string, string | number | boolean | string[]>;
|
||||
|
||||
export function parseAgentDefinitionMarkdown(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): AgentDefinition | AgentDefinitionLoadError {
|
||||
const extracted = extractFrontmatter(content, options);
|
||||
if (isLoadError(extracted)) {
|
||||
return extracted;
|
||||
}
|
||||
|
||||
const systemPrompt = extracted.body.trim();
|
||||
if (!systemPrompt) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'empty_body',
|
||||
message: 'Agent definition markdown body must contain the system prompt.',
|
||||
};
|
||||
}
|
||||
|
||||
const frontmatter = parseFrontmatter(extracted.frontmatter, options);
|
||||
if (isLoadError(frontmatter)) {
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
try {
|
||||
return normalizeAgentDefinition({
|
||||
...frontmatter,
|
||||
source: options.source,
|
||||
getSystemPrompt: () => systemPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'invalid_definition',
|
||||
message: 'Agent definition frontmatter does not match AgentDefinition.',
|
||||
issues:
|
||||
error instanceof ZodError ? error.issues.map((issue) => issue.message) : [String(error)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadProjectAgentDefinitions(
|
||||
projectRoot: string
|
||||
): Promise<AgentDefinitionLoadResult> {
|
||||
const definitionsDir = join(projectRoot, PROJECT_AGENT_DEFINITIONS_DIR);
|
||||
const result: AgentDefinitionLoadResult = { definitions: [], failedFiles: [] };
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(definitionsDir);
|
||||
} catch (error) {
|
||||
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return {
|
||||
definitions: [],
|
||||
failedFiles: [
|
||||
{
|
||||
source: 'project',
|
||||
filePath: definitionsDir,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
for (const entry of entries.sort()) {
|
||||
if (!entry.endsWith('.md')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(definitionsDir, entry);
|
||||
try {
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const parsed = parseAgentDefinitionMarkdown(content, { source: 'project', filePath });
|
||||
if (isLoadError(parsed)) {
|
||||
result.failedFiles.push(parsed);
|
||||
} else {
|
||||
result.definitions.push(parsed);
|
||||
}
|
||||
} catch (error) {
|
||||
result.failedFiles.push({
|
||||
source: 'project',
|
||||
filePath,
|
||||
code: 'read_error',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractFrontmatter(
|
||||
content: string,
|
||||
options: MarkdownParseOptions
|
||||
): { frontmatter: string; body: string } | AgentDefinitionLoadError {
|
||||
const normalized = content.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
||||
if (!normalized.startsWith('---\n')) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'missing_frontmatter',
|
||||
message: 'Agent definition markdown must start with --- frontmatter.',
|
||||
};
|
||||
}
|
||||
|
||||
const closingMarker = '\n---\n';
|
||||
const closingIndex = normalized.indexOf(closingMarker, 4);
|
||||
if (closingIndex === -1) {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message: 'Agent definition markdown frontmatter must close with --- on its own line.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter: normalized.slice(4, closingIndex),
|
||||
body: normalized.slice(closingIndex + closingMarker.length),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFrontmatter(
|
||||
frontmatter: string,
|
||||
options: MarkdownParseOptions
|
||||
): FrontmatterRecord | AgentDefinitionLoadError {
|
||||
const parsed: FrontmatterRecord = {};
|
||||
const lines = frontmatter.split('\n');
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match = /^(\w+):\s*(.*)$/.exec(line);
|
||||
if (!match) {
|
||||
return malformedFrontmatter(options, `Invalid frontmatter line: ${line}`);
|
||||
}
|
||||
|
||||
const key = match[1];
|
||||
const rawValue = match[2];
|
||||
if (rawValue === '') {
|
||||
const values: string[] = [];
|
||||
while (index + 1 < lines.length && /^\s+-\s+/.test(lines[index + 1])) {
|
||||
index += 1;
|
||||
values.push(unquote(lines[index].replace(/^\s+-\s+/, '').trim()));
|
||||
}
|
||||
parsed[key] = values;
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = parseFrontmatterValue(rawValue.trim(), options);
|
||||
if (isLoadError(value)) {
|
||||
return value;
|
||||
}
|
||||
parsed[key] = value;
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseFrontmatterValue(
|
||||
value: string,
|
||||
options: MarkdownParseOptions
|
||||
): string | number | boolean | string[] | AgentDefinitionLoadError {
|
||||
if (value.startsWith('[')) {
|
||||
if (!value.endsWith(']')) {
|
||||
return malformedFrontmatter(options, `Invalid inline array: ${value}`);
|
||||
}
|
||||
|
||||
const inner = value.slice(1, -1).trim();
|
||||
return inner ? inner.split(',').map((item) => unquote(item.trim())) : [];
|
||||
}
|
||||
|
||||
if (value === 'true') {
|
||||
return true;
|
||||
}
|
||||
if (value === 'false') {
|
||||
return false;
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
return unquote(value);
|
||||
}
|
||||
|
||||
function unquote(value: string): string {
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
return value.slice(1, -1);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function malformedFrontmatter(
|
||||
options: MarkdownParseOptions,
|
||||
message: string
|
||||
): AgentDefinitionLoadError {
|
||||
return {
|
||||
source: options.source,
|
||||
filePath: options.filePath,
|
||||
code: 'malformed_frontmatter',
|
||||
message,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoadError(value: unknown): value is AgentDefinitionLoadError {
|
||||
return typeof value === 'object' && value !== null && 'code' in value;
|
||||
}
|
||||
|
||||
function isNodeErrorCode(error: unknown, code: string): boolean {
|
||||
return typeof error === 'object' && error !== null && 'code' in error && error.code === code;
|
||||
}
|
||||
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
62
src/agent-kernel/definitions/agent-registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { AgentDefinition } from './agent-definition';
|
||||
import { normalizeAgentDefinition } from './agent-definition';
|
||||
import type { AgentDefinitionLoadError } from './agent-loader';
|
||||
import { loadProjectAgentDefinitions } from './agent-loader';
|
||||
|
||||
export interface AgentRegistry {
|
||||
allAgents: AgentDefinition[];
|
||||
activeAgents: AgentDefinition[];
|
||||
failedFiles: AgentDefinitionLoadError[];
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined;
|
||||
}
|
||||
|
||||
export interface AgentRegistryInput {
|
||||
builtIn?: unknown[];
|
||||
plugin?: unknown[];
|
||||
user?: unknown[];
|
||||
project?: unknown[];
|
||||
failedFiles?: AgentDefinitionLoadError[];
|
||||
}
|
||||
|
||||
export interface LoadAgentRegistryOptions extends AgentRegistryInput {
|
||||
projectRoot?: string;
|
||||
}
|
||||
|
||||
export function createAgentRegistry(input: AgentRegistryInput = {}): AgentRegistry {
|
||||
const allAgents = [
|
||||
...(input.builtIn ?? []),
|
||||
...(input.plugin ?? []),
|
||||
...(input.user ?? []),
|
||||
...(input.project ?? []),
|
||||
].map((definition) => normalizeAgentDefinition(definition));
|
||||
const activeByType = new Map<string, AgentDefinition>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
activeByType.set(agent.agentType, agent);
|
||||
}
|
||||
|
||||
return {
|
||||
allAgents,
|
||||
activeAgents: Array.from(activeByType.values()),
|
||||
failedFiles: input.failedFiles ?? [],
|
||||
getActiveAgent(agentType: string): AgentDefinition | undefined {
|
||||
return activeByType.get(agentType);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadAgentRegistry(
|
||||
options: LoadAgentRegistryOptions = {}
|
||||
): Promise<AgentRegistry> {
|
||||
const projectLoadResult = options.projectRoot
|
||||
? await loadProjectAgentDefinitions(options.projectRoot)
|
||||
: { definitions: [], failedFiles: [] };
|
||||
|
||||
return createAgentRegistry({
|
||||
builtIn: options.builtIn,
|
||||
plugin: options.plugin,
|
||||
user: options.user,
|
||||
project: [...(options.project ?? []), ...projectLoadResult.definitions],
|
||||
failedFiles: [...(options.failedFiles ?? []), ...projectLoadResult.failedFiles],
|
||||
});
|
||||
}
|
||||
28
src/agent-kernel/definitions/index.ts
Normal file
28
src/agent-kernel/definitions/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
export {
|
||||
AGENT_DEFINITION_SOURCES,
|
||||
AGENT_ISOLATIONS,
|
||||
AGENT_PERMISSION_MODES,
|
||||
agentDefinitionSchema,
|
||||
isAgentDefinition,
|
||||
normalizeAgentDefinition,
|
||||
parseAgentDefinition,
|
||||
} from './agent-definition';
|
||||
export {
|
||||
PROJECT_AGENT_DEFINITIONS_DIR,
|
||||
loadProjectAgentDefinitions,
|
||||
parseAgentDefinitionMarkdown,
|
||||
} from './agent-loader';
|
||||
export { createAgentRegistry, loadAgentRegistry } from './agent-registry';
|
||||
export type {
|
||||
AgentDefinition,
|
||||
AgentDefinitionHooks,
|
||||
AgentDefinitionSource,
|
||||
AgentIsolation,
|
||||
AgentPermissionMode,
|
||||
} from './agent-definition';
|
||||
export type {
|
||||
AgentDefinitionLoadError,
|
||||
AgentDefinitionLoadErrorCode,
|
||||
AgentDefinitionLoadResult,
|
||||
} from './agent-loader';
|
||||
export type { AgentRegistry, AgentRegistryInput, LoadAgentRegistryOptions } from './agent-registry';
|
||||
@@ -1,219 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { KernelHookRegistry } from '../kernel-hook-registry';
|
||||
import { runKernelHooks } from '../kernel-hook-runner';
|
||||
import type { KernelHookDefinition, KernelHookInput } from '../kernel-hook-types';
|
||||
|
||||
const baseContext = {
|
||||
workspacePath: '/tmp/workspace',
|
||||
mirrorPath: '/tmp/mirror',
|
||||
runId: 'run-1',
|
||||
};
|
||||
|
||||
function makeRegistry(hooks: KernelHookDefinition[]): KernelHookRegistry {
|
||||
const registry = new KernelHookRegistry();
|
||||
for (const hook of hooks) {
|
||||
registry.register(hook);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
function makeHook(
|
||||
name: string,
|
||||
event: KernelHookInput['event'],
|
||||
execute: KernelHookDefinition['execute']
|
||||
): KernelHookDefinition {
|
||||
return {
|
||||
name,
|
||||
event,
|
||||
description: `Test hook ${name}`,
|
||||
execute,
|
||||
};
|
||||
}
|
||||
|
||||
describe('runKernelHooks', () => {
|
||||
test.each([
|
||||
[
|
||||
'SessionStart',
|
||||
{
|
||||
event: 'SessionStart',
|
||||
sessionId: 'session-1',
|
||||
runId: 'run-1',
|
||||
scopeKey: 'repo#1',
|
||||
},
|
||||
],
|
||||
[
|
||||
'SubagentStart',
|
||||
{
|
||||
event: 'SubagentStart',
|
||||
sessionId: 'session-1',
|
||||
runId: 'run-1',
|
||||
subagentName: 'test:subagent',
|
||||
agentId: 'agent-1',
|
||||
packet: {
|
||||
input: { focus: 'test' },
|
||||
goal: 'test goal',
|
||||
parentTaskName: 'test:task',
|
||||
parentSessionId: 'session-1',
|
||||
parentRunId: 'run-1',
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
'PermissionRequest',
|
||||
{
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
],
|
||||
[
|
||||
'PreToolUse',
|
||||
{
|
||||
event: 'PreToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
[
|
||||
'PostToolUse',
|
||||
{
|
||||
event: 'PostToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
output: { ok: true },
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
[
|
||||
'PostToolUseFailure',
|
||||
{
|
||||
event: 'PostToolUseFailure',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
error: 'boom',
|
||||
context: baseContext,
|
||||
},
|
||||
],
|
||||
] as const)('dispatches %s to matching hooks', async (_label, input) => {
|
||||
const executed: string[] = [];
|
||||
const registry = makeRegistry([
|
||||
makeHook('first', input.event, async () => {
|
||||
executed.push('first');
|
||||
return { additionalContext: 'ctx-1' };
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({ registry, input });
|
||||
|
||||
expect(executed).toEqual(['first']);
|
||||
expect(result.results).toHaveLength(1);
|
||||
expect(result.additionalContexts).toEqual(['ctx-1']);
|
||||
});
|
||||
|
||||
test('aggregates additionalContext values and lets later updatedInput override earlier values', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('first', 'PreToolUse', async () => ({
|
||||
additionalContext: 'ctx-1',
|
||||
updatedInput: { value: 'first' },
|
||||
})),
|
||||
makeHook('second', 'PreToolUse', async () => ({
|
||||
additionalContext: 'ctx-2',
|
||||
updatedInput: { value: 'second' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PreToolUse',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-1', 'ctx-2']);
|
||||
expect(result.updatedInput).toEqual({ value: 'second' });
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('propagates blockingReason when a hook returns decision block', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('before', 'PermissionRequest', async () => ({
|
||||
additionalContext: 'ctx-before',
|
||||
updatedInput: { value: 'before' },
|
||||
})),
|
||||
makeHook('blocker', 'PermissionRequest', async () => ({
|
||||
decision: 'block',
|
||||
reason: 'blocked by policy',
|
||||
additionalContext: 'ctx-blocker',
|
||||
updatedInput: { value: 'blocked' },
|
||||
})),
|
||||
makeHook('after', 'PermissionRequest', async () => ({
|
||||
additionalContext: 'ctx-after',
|
||||
updatedInput: { value: 'after' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-before', 'ctx-blocker']);
|
||||
expect(result.updatedInput).toEqual({ value: 'blocked' });
|
||||
expect(result.blockingReason).toBe('blocked by policy');
|
||||
expect(result.results).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('preserves approve decisions for PermissionRequest without introducing a blocking reason', async () => {
|
||||
const registry = makeRegistry([
|
||||
makeHook('approver', 'PermissionRequest', async () => ({
|
||||
decision: 'approve',
|
||||
reason: 'approved by reviewer',
|
||||
additionalContext: 'ctx-approve',
|
||||
updatedInput: { value: 'approved' },
|
||||
})),
|
||||
]);
|
||||
|
||||
const result = await runKernelHooks({
|
||||
registry,
|
||||
input: {
|
||||
event: 'PermissionRequest',
|
||||
toolName: 'write_file',
|
||||
toolCallId: 'call-1',
|
||||
input: { value: 'raw' },
|
||||
context: baseContext,
|
||||
suggestedBehavior: 'ask',
|
||||
reason: 'needs approval',
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.additionalContexts).toEqual(['ctx-approve']);
|
||||
expect(result.updatedInput).toEqual({ value: 'approved' });
|
||||
expect(result.blockingReason).toBeUndefined();
|
||||
expect(result.results).toEqual([
|
||||
expect.objectContaining({
|
||||
decision: 'approve',
|
||||
reason: 'approved by reviewer',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import type { KernelHookDefinition, KernelHookEventName } from './kernel-hook-types';
|
||||
|
||||
export class KernelHookRegistry {
|
||||
private readonly hooks = new Map<KernelHookEventName, KernelHookDefinition[]>();
|
||||
|
||||
register(hook: KernelHookDefinition): void {
|
||||
const existing = this.hooks.get(hook.event) ?? [];
|
||||
existing.push(hook);
|
||||
this.hooks.set(hook.event, existing);
|
||||
}
|
||||
|
||||
get(event: KernelHookEventName): KernelHookDefinition[] {
|
||||
return this.hooks.get(event) ?? [];
|
||||
}
|
||||
|
||||
getAll(): KernelHookDefinition[] {
|
||||
return [...this.hooks.values()].flat();
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { KernelHookRegistry } from './kernel-hook-registry';
|
||||
import type { KernelHookInput, KernelLifecycleResult } from './kernel-hook-types';
|
||||
|
||||
export async function runKernelHooks(params: {
|
||||
registry: KernelHookRegistry;
|
||||
input: KernelHookInput;
|
||||
}): Promise<KernelLifecycleResult> {
|
||||
const hooks = params.registry.get(params.input.event);
|
||||
const results = [] as KernelLifecycleResult['results'];
|
||||
const additionalContexts: string[] = [];
|
||||
let updatedInput: Record<string, unknown> | undefined;
|
||||
let blockingReason: string | undefined;
|
||||
|
||||
for (const hook of hooks) {
|
||||
try {
|
||||
const result = await hook.execute(params.input);
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
results.push(result);
|
||||
if (result.additionalContext) {
|
||||
additionalContexts.push(result.additionalContext);
|
||||
}
|
||||
if (result.updatedInput) {
|
||||
updatedInput = result.updatedInput;
|
||||
}
|
||||
if (result.continue === false || result.decision === 'block') {
|
||||
blockingReason = result.reason ?? `Execution blocked by hook ${hook.name}`;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Kernel hook 执行失败', {
|
||||
hookName: hook.name,
|
||||
event: params.input.event,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
results,
|
||||
additionalContexts,
|
||||
updatedInput,
|
||||
blockingReason,
|
||||
};
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { ToolExecutionContext } from '../../review/tools/types';
|
||||
import type { KernelDelegationPacket, KernelSubagentInvocationResult } from '../types';
|
||||
|
||||
export type KernelHookEventName =
|
||||
| 'SessionStart'
|
||||
| 'SubagentStart'
|
||||
| 'PermissionRequest'
|
||||
| 'PreToolUse'
|
||||
| 'PostToolUse'
|
||||
| 'PostToolUseFailure';
|
||||
|
||||
export interface SessionStartHookInput {
|
||||
event: 'SessionStart';
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
scopeKey: string;
|
||||
}
|
||||
|
||||
export interface SubagentStartHookInput {
|
||||
event: 'SubagentStart';
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
subagentName: string;
|
||||
agentId: string;
|
||||
packet: KernelDelegationPacket;
|
||||
}
|
||||
|
||||
export interface PreToolUseHookInput {
|
||||
event: 'PreToolUse';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export interface PermissionRequestHookInput {
|
||||
event: 'PermissionRequest';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
context: ToolExecutionContext;
|
||||
suggestedBehavior: 'ask' | 'deny';
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface PostToolUseHookInput {
|
||||
event: 'PostToolUse';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
output: unknown;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export interface PostToolUseFailureHookInput {
|
||||
event: 'PostToolUseFailure';
|
||||
toolName: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
error: string;
|
||||
context: ToolExecutionContext;
|
||||
}
|
||||
|
||||
export type KernelHookInput =
|
||||
| SessionStartHookInput
|
||||
| SubagentStartHookInput
|
||||
| PermissionRequestHookInput
|
||||
| PreToolUseHookInput
|
||||
| PostToolUseHookInput
|
||||
| PostToolUseFailureHookInput;
|
||||
|
||||
export interface KernelHookResult {
|
||||
continue?: boolean;
|
||||
additionalContext?: string;
|
||||
updatedInput?: Record<string, unknown>;
|
||||
decision?: 'approve' | 'block';
|
||||
reason?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface KernelHookDefinition {
|
||||
name: string;
|
||||
event: KernelHookEventName;
|
||||
description: string;
|
||||
execute(input: KernelHookInput): Promise<KernelHookResult | undefined>;
|
||||
}
|
||||
|
||||
export interface KernelLifecycleResult {
|
||||
results: KernelHookResult[];
|
||||
additionalContexts: string[];
|
||||
updatedInput?: Record<string, unknown>;
|
||||
blockingReason?: string;
|
||||
}
|
||||
|
||||
export interface KernelSubagentCompletionEnvelope {
|
||||
invocationId: string;
|
||||
subagentName: string;
|
||||
result: KernelSubagentInvocationResult;
|
||||
}
|
||||
@@ -0,0 +1,620 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import { ScriptedMockLLM, scriptedTurn } from '../../../llm/e2e-mock';
|
||||
import { createAgentRegistry } from '../../definitions';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import { SubagentRunner } from '../../subagents/subagent-runner';
|
||||
import { createSpawnSubagentTool } from '../../tools';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentTool } from '../types';
|
||||
|
||||
function baseAgentDefinition() {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for delegated analysis.',
|
||||
source: 'built-in' as const,
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 6,
|
||||
permissionMode: 'default' as const,
|
||||
background: false,
|
||||
isolation: 'none' as const,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Scripted Mock LLM dynamic agent flows', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `dynamic-agent-scripted-mock-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
function makeTools(record: { submissions: unknown[] }) {
|
||||
const readFileTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'read_file',
|
||||
description: 'Read deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { path: { type: 'string' } },
|
||||
required: ['path'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({ path: (args as { path: string }).path, content: 'const value = 1;' }),
|
||||
};
|
||||
|
||||
const searchCodeTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'search_code',
|
||||
description: 'Search deterministic test fixture.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (args) => ({
|
||||
matches: [{ path: 'src/app.ts', line: 1, query: (args as { query: string }).query }],
|
||||
}),
|
||||
};
|
||||
|
||||
const submitReviewFindingsTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'submit_review_findings',
|
||||
description: 'Capture deterministic submission payload.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
summaryMarkdown: { type: 'string' },
|
||||
findings: { type: 'array', items: { type: 'object' } },
|
||||
},
|
||||
required: ['summaryMarkdown', 'findings'],
|
||||
},
|
||||
},
|
||||
execute: (args) => {
|
||||
record.submissions.push(structuredClone(args));
|
||||
return { accepted: true };
|
||||
},
|
||||
};
|
||||
|
||||
return { readFileTool, searchCodeTool, submitReviewFindingsTool };
|
||||
}
|
||||
|
||||
test('deterministically scripts main->spawn_subagent->submit_review_findings flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Inspect changed file',
|
||||
prompt: 'Check correctness risks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"value"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Subagent summary: potential correctness issue found.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'Found one issue.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Review finalized.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Start dynamic review.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('Review finalized.');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code', 'read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Found one issue.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports deterministic no-subagent completion flow', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'main-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({ summaryMarkdown: 'No issues found.', findings: [] }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Done without subagent.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Review directly.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual(['read_file', 'submit_review_findings']);
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.status).toBe('completed');
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 3,
|
||||
toolCalls: 2,
|
||||
finalText: 'Done without subagent.',
|
||||
});
|
||||
expect(tree?.invocations).toHaveLength(0);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'read_file',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'No issues found.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('supports multiple subagent spawns in one main run with distinct child sessions', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child one',
|
||||
prompt: 'Inspect alpha path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-search-1', name: 'search_code', arguments: '{"query":"alpha"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child one summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-2',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Run child two',
|
||||
prompt: 'Inspect beta path.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-read-1', name: 'read_file', arguments: '{"path":"src/app.ts"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'subagent', turn: scriptedTurn({ content: 'Child two summary.' }) },
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Two children completed.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed multi-child flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [readFileTool, spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
agentType: 'review-main-agent',
|
||||
userMessage: 'Run two delegated checks.',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
expect(scriptedModel.toolCallSequence('subagent')).toEqual(['search_code', 'read_file']);
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(2);
|
||||
expect(tree?.invocations[0].status).toBe('completed');
|
||||
expect(tree?.invocations[1].status).toBe('completed');
|
||||
expect(tree?.invocations[0].childSessionId).not.toBe(tree?.invocations[1].childSessionId);
|
||||
expect(
|
||||
tree?.invocations[0].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['search_code']);
|
||||
expect(
|
||||
tree?.invocations[1].childSession?.toolCalls.map((toolCall) => toolCall.toolName)
|
||||
).toEqual(['read_file']);
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Two children completed.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('propagates structured subagent failure and still allows main completion', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate quickly',
|
||||
prompt: 'Run child checks.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Subagent failed; no findings.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main handled child failure.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({ builtIn: [baseAgentDefinition()] }),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run subagent and continue on failure.',
|
||||
maxTurns: 6,
|
||||
maxToolCalls: 6,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondMainRequest = scriptedModel.calls.filter((call) => call.session === 'main')[1];
|
||||
const lastMessage = secondMainRequest.request.messages.at(-1);
|
||||
expect(lastMessage?.role).toBe('tool');
|
||||
expect(lastMessage?.content).toContain('No scripted mock turn queued for session');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].result).toMatchObject({
|
||||
status: 'failed',
|
||||
error: {
|
||||
code: 'Error',
|
||||
message: "No scripted mock turn queued for session 'subagent'",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Subagent failed; no findings.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
|
||||
test('filters disallowed child tools and persists deterministic failed tool call path', async () => {
|
||||
const scriptedModel = new ScriptedMockLLM({
|
||||
resolveSession: (request) => (request.model === 'subagent-model' ? 'subagent' : 'main'),
|
||||
steps: [
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Restricted run',
|
||||
prompt: 'Try forbidden search first.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{ id: 'sub-denied-1', name: 'search_code', arguments: '{"query":"restricted"}' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
session: 'subagent',
|
||||
turn: scriptedTurn({ content: 'Child observed denied tool and completed.' }),
|
||||
},
|
||||
{
|
||||
session: 'main',
|
||||
turn: scriptedTurn({
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'main-submit-1',
|
||||
name: 'submit_review_findings',
|
||||
arguments: JSON.stringify({
|
||||
summaryMarkdown: 'Permission filtered as expected.',
|
||||
findings: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{ session: 'main', turn: scriptedTurn({ content: 'Main completed restricted flow.' }) },
|
||||
],
|
||||
});
|
||||
|
||||
const submissionRecord = { submissions: [] as unknown[] };
|
||||
const { readFileTool, searchCodeTool, submitReviewFindingsTool } = makeTools(submissionRecord);
|
||||
const subagentRunner = new SubagentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [searchCodeTool, readFileTool],
|
||||
});
|
||||
const spawnSubagentTool = createSpawnSubagentTool({
|
||||
agentRegistry: createAgentRegistry({
|
||||
builtIn: [
|
||||
{
|
||||
...baseAgentDefinition(),
|
||||
tools: ['search_code', 'read_file'],
|
||||
disallowedTools: ['search_code'],
|
||||
},
|
||||
],
|
||||
}),
|
||||
executor: subagentRunner,
|
||||
defaultSubagentModel: 'subagent-model',
|
||||
});
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: scriptedModel,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [spawnSubagentTool, submitReviewFindingsTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'main-model',
|
||||
userMessage: 'Run with restricted subagent tools.',
|
||||
maxTurns: 8,
|
||||
maxToolCalls: 8,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(scriptedModel.toolCallSequence('main')).toEqual([
|
||||
'spawn_subagent',
|
||||
'submit_review_findings',
|
||||
]);
|
||||
const secondSubagentRequest = scriptedModel.calls.filter(
|
||||
(call) => call.session === 'subagent'
|
||||
)[1];
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.role).toBe('tool');
|
||||
expect(secondSubagentRequest.request.messages.at(-1)?.content).toContain('ToolNotFoundError');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['search_code', 'read_file'],
|
||||
disallowedToolNames: ['search_code'],
|
||||
deniedToolNames: ['search_code'],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'search_code',
|
||||
status: 'failed',
|
||||
arguments: { query: 'restricted' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'search_code' is not registered",
|
||||
},
|
||||
});
|
||||
expect(submissionRecord.submissions).toEqual([
|
||||
{ summaryMarkdown: 'Permission filtered as expected.', findings: [] },
|
||||
]);
|
||||
scriptedModel.assertExhausted();
|
||||
});
|
||||
});
|
||||
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
358
src/agent-kernel/loop/__tests__/main-agent-runner.test.ts
Normal file
@@ -0,0 +1,358 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, initDatabase } from '../../../db/database';
|
||||
import type { LLMChatRequest, LLMChatResponse } from '../../../llm/types';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { MainAgentRunner } from '../main-agent-runner';
|
||||
import type { MainAgentModelClient, MainAgentTool } from '../types';
|
||||
|
||||
function response(partial: Partial<LLMChatResponse>): LLMChatResponse {
|
||||
return {
|
||||
content: partial.content ?? null,
|
||||
toolCalls: partial.toolCalls ?? [],
|
||||
finishReason: partial.finishReason ?? 'stop',
|
||||
usage: partial.usage ?? {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeModelClient implements MainAgentModelClient {
|
||||
requests: LLMChatRequest[] = [];
|
||||
|
||||
constructor(private readonly responses: LLMChatResponse[]) {}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
this.requests.push(structuredClone(request));
|
||||
const next = this.responses.shift();
|
||||
if (!next) throw new Error('No fake model response queued');
|
||||
return next;
|
||||
}
|
||||
}
|
||||
|
||||
const lookupTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'lookup',
|
||||
description: 'Look up a deterministic value.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string' },
|
||||
},
|
||||
required: ['query'],
|
||||
},
|
||||
},
|
||||
execute: (argumentsValue) => ({ echoed: (argumentsValue as { query: string }).query }),
|
||||
};
|
||||
|
||||
describe('MainAgentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `main-agent-runner-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('runs tool call, appends tool result, then returns final answer', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
content: null,
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'final answer' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer with a tool',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('final answer');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-1',
|
||||
content: JSON.stringify({ ok: true, value: { echoed: 'alpha' } }),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.toolCalls[0].result).toEqual({ echoed: 'alpha' });
|
||||
expect(tree?.finalResult).toEqual({
|
||||
status: 'completed',
|
||||
turns: 2,
|
||||
toolCalls: 1,
|
||||
finalText: 'final answer',
|
||||
});
|
||||
});
|
||||
|
||||
test('completes on final assistant answer with no tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([response({ content: 'plain final' })]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'answer directly',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(0);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.messages).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('stops runaway model at max turns', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' }],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'keep calling tools',
|
||||
maxTurns: 2,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_turns_reached');
|
||||
expect(result.turns).toBe(2);
|
||||
expect(result.toolCalls).toBe(2);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('stops before exceeding max tool calls', async () => {
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{ id: 'call-1', name: 'lookup', arguments: '{"query":"one"}' },
|
||||
{ id: 'call-2', name: 'lookup', arguments: '{"query":"two"}' },
|
||||
],
|
||||
}),
|
||||
]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'too many tools',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 1,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_tool_calls_reached');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(agentSessionRepository.getSessionTree(result.sessionId)?.toolCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('records tool execution errors as structured tool results and continues', async () => {
|
||||
const failingTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_lookup',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('lookup failed');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-1', name: 'fail_lookup', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'recovered' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failingTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'recover from tool error',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 2,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls[0].status).toBe('failed');
|
||||
expect(tree?.toolCalls[0].error).toEqual({ name: 'Error', message: 'lookup failed' });
|
||||
expect(modelClient.requests[1].messages.at(-1)?.content).toBe(
|
||||
JSON.stringify({ ok: false, error: { name: 'Error', message: 'lookup failed' } })
|
||||
);
|
||||
});
|
||||
|
||||
test('stops on maxEmptyResponses', async () => {
|
||||
const modelClient = new FakeModelClient([
|
||||
response({ content: '' }),
|
||||
response({ content: '' }),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test empty responses',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxEmptyResponses: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_empty_responses');
|
||||
expect(result.turns).toBe(2);
|
||||
});
|
||||
|
||||
test('stops on maxConsecutiveToolFailures', async () => {
|
||||
const failTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'fail_tool',
|
||||
description: 'Always fails.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'fail_tool', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'should not reach' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [failTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test tool failures',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxConsecutiveToolFailures: 3,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('max_consecutive_tool_failures');
|
||||
});
|
||||
|
||||
test('refuses subagent spawn beyond maxSubagents and allows summary', async () => {
|
||||
const subagentTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description: 'Spawn a subagent.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ status: 'completed' }),
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c1', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c2', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'c3', name: 'spawn_subagent', arguments: '{}' }],
|
||||
}),
|
||||
response({ content: 'review complete with 2 subagents' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [subagentTool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
model: 'mock-model',
|
||||
userMessage: 'test subagent limit',
|
||||
maxTurns: 10,
|
||||
maxToolCalls: 10,
|
||||
timeoutMs: 60_000,
|
||||
maxSubagents: 2,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.finalText).toBe('review complete with 2 subagents');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/loop/index.ts
Normal file
2
src/agent-kernel/loop/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main-agent-runner';
|
||||
export * from './types';
|
||||
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
346
src/agent-kernel/loop/main-agent-runner.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import type { LLMMessage, LLMToolCall } from '../../llm/types';
|
||||
import { agentSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
MainAgentRunInput,
|
||||
MainAgentRunResult,
|
||||
MainAgentRunnerOptions,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
ToolExecutionResult,
|
||||
} from './types';
|
||||
|
||||
function parseToolArguments(toolCall: LLMToolCall): ToolExecutionResult {
|
||||
try {
|
||||
return { ok: true, value: JSON.parse(toolCall.arguments || '{}') };
|
||||
} catch (error) {
|
||||
const parsedError = error instanceof Error ? error : new Error(String(error));
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: parsedError.name,
|
||||
message: parsedError.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function stringifyToolResult(result: ToolExecutionResult): string {
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ToolExecutionResult['error'] {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
export class MainAgentRunner {
|
||||
private readonly modelClient: MainAgentRunnerOptions['modelClient'];
|
||||
private readonly transcriptRepository: MainAgentTranscriptRepository;
|
||||
private readonly toolsByName: Map<string, MainAgentTool>;
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(options: MainAgentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.toolsByName = new Map((options.tools ?? []).map((tool) => [tool.definition.name, tool]));
|
||||
this.now = options.now ?? Date.now;
|
||||
}
|
||||
|
||||
async run(input: MainAgentRunInput): Promise<MainAgentRunResult> {
|
||||
const startedAt = this.now();
|
||||
const sessionId =
|
||||
input.sessionId ??
|
||||
this.transcriptRepository.createSession({
|
||||
agentType: input.session?.agentType ?? input.agentType ?? 'main',
|
||||
model: input.session?.model ?? input.model,
|
||||
parentSessionId: input.session?.parentSessionId,
|
||||
parentInvocationId: input.session?.parentInvocationId,
|
||||
status: input.session?.status,
|
||||
metadata: input.session?.metadata,
|
||||
}).id;
|
||||
|
||||
const messages: LLMMessage[] = [];
|
||||
if (input.systemPrompt) {
|
||||
messages.push({ role: 'system', content: input.systemPrompt });
|
||||
}
|
||||
|
||||
const userMessage: LLMMessage = { role: 'user', content: input.userMessage };
|
||||
messages.push(userMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'user',
|
||||
content: { text: input.userMessage },
|
||||
});
|
||||
|
||||
let turns = 0;
|
||||
let toolCalls = 0;
|
||||
let subagentCount = 0;
|
||||
let emptyResponseCount = 0;
|
||||
let consecutiveToolFailures = 0;
|
||||
const maxSubagents = input.maxSubagents ?? Number.POSITIVE_INFINITY;
|
||||
const maxEmptyResponses = input.maxEmptyResponses ?? 3;
|
||||
const maxConsecutiveToolFailures = input.maxConsecutiveToolFailures ?? 5;
|
||||
|
||||
while (true) {
|
||||
const budgetStatus = this.getBudgetStatus(
|
||||
input,
|
||||
startedAt,
|
||||
turns,
|
||||
emptyResponseCount,
|
||||
consecutiveToolFailures,
|
||||
maxEmptyResponses,
|
||||
maxConsecutiveToolFailures
|
||||
);
|
||||
if (budgetStatus) {
|
||||
return this.finish(sessionId, budgetStatus, turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
const response = await this.modelClient.chat({
|
||||
messages,
|
||||
model: input.model,
|
||||
temperature: input.temperature,
|
||||
maxTokens: input.maxTokens,
|
||||
responseFormat: input.responseFormat,
|
||||
providerOptions: input.providerOptions,
|
||||
tools: [...this.toolsByName.values()].map((tool) => tool.definition),
|
||||
});
|
||||
|
||||
turns += 1;
|
||||
|
||||
if (!response.content?.trim() && response.toolCalls.length === 0) {
|
||||
emptyResponseCount += 1;
|
||||
messages.push({ role: 'assistant', content: '' });
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: { text: '' },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
emptyResponseCount = 0;
|
||||
|
||||
const assistantMessage: LLMMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
};
|
||||
messages.push(assistantMessage);
|
||||
|
||||
const assistantRecord = this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'assistant',
|
||||
content: {
|
||||
text: response.content ?? '',
|
||||
toolCalls: response.toolCalls,
|
||||
finishReason: response.finishReason,
|
||||
usage: response.usage,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.toolCalls.length === 0) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'completed',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages,
|
||||
response.content ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
for (const toolCall of response.toolCalls) {
|
||||
if (this.isTimedOut(input, startedAt)) {
|
||||
return this.finish(sessionId, 'timeout_reached', turns, toolCalls, messages);
|
||||
}
|
||||
if (toolCalls >= input.maxToolCalls) {
|
||||
return this.finish(sessionId, 'max_tool_calls_reached', turns, toolCalls, messages);
|
||||
}
|
||||
|
||||
if (toolCall.name === 'spawn_subagent') {
|
||||
if (subagentCount >= maxSubagents) {
|
||||
const refusalMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents}). Please summarize your findings instead.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
messages.push(refusalMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result: {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'BudgetExceeded',
|
||||
message: `Subagent limit reached (${maxSubagents})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
continue;
|
||||
}
|
||||
subagentCount += 1;
|
||||
}
|
||||
|
||||
const result = await this.executeTool(toolCall, sessionId, input.model, turns);
|
||||
toolCalls += 1;
|
||||
if (!result.ok) {
|
||||
consecutiveToolFailures += 1;
|
||||
} else {
|
||||
consecutiveToolFailures = 0;
|
||||
}
|
||||
|
||||
this.transcriptRepository.appendToolCall({
|
||||
sessionId,
|
||||
messageId: assistantRecord.id,
|
||||
toolName: toolCall.name,
|
||||
status: result.ok ? 'completed' : 'failed',
|
||||
arguments: parseToolArguments(toolCall).value ?? {},
|
||||
result: result.ok ? result.value : undefined,
|
||||
error: result.ok ? undefined : result.error,
|
||||
});
|
||||
|
||||
const toolMessage: LLMMessage = {
|
||||
role: 'tool',
|
||||
toolCallId: toolCall.id,
|
||||
content: stringifyToolResult(result),
|
||||
};
|
||||
messages.push(toolMessage);
|
||||
this.transcriptRepository.appendMessage({
|
||||
sessionId,
|
||||
role: 'tool',
|
||||
content: {
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.name,
|
||||
result,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.ok && consecutiveToolFailures >= maxConsecutiveToolFailures) {
|
||||
return this.finish(
|
||||
sessionId,
|
||||
'max_consecutive_tool_failures',
|
||||
turns,
|
||||
toolCalls,
|
||||
messages
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getBudgetStatus(
|
||||
input: MainAgentRunInput,
|
||||
startedAt: number,
|
||||
turns: number,
|
||||
emptyResponseCount: number,
|
||||
consecutiveToolFailures: number,
|
||||
maxEmptyResponses: number,
|
||||
maxConsecutiveToolFailures: number
|
||||
): MainAgentTerminalStatus | undefined {
|
||||
if (this.isTimedOut(input, startedAt)) return 'timeout_reached';
|
||||
if (turns >= input.maxTurns) return 'max_turns_reached';
|
||||
if (emptyResponseCount >= maxEmptyResponses) return 'max_empty_responses';
|
||||
if (consecutiveToolFailures >= maxConsecutiveToolFailures)
|
||||
return 'max_consecutive_tool_failures';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private isTimedOut(input: MainAgentRunInput, startedAt: number): boolean {
|
||||
return this.now() - startedAt >= input.timeoutMs;
|
||||
}
|
||||
|
||||
private async executeTool(
|
||||
toolCall: LLMToolCall,
|
||||
sessionId: string,
|
||||
model: string,
|
||||
turn: number
|
||||
): Promise<ToolExecutionResult> {
|
||||
const parsedArguments = parseToolArguments(toolCall);
|
||||
if (!parsedArguments.ok) return parsedArguments;
|
||||
|
||||
const tool = this.toolsByName.get(toolCall.name);
|
||||
if (!tool) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: `Tool '${toolCall.name}' is not registered`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await tool.execute(parsedArguments.value, {
|
||||
sessionId,
|
||||
model,
|
||||
toolCall,
|
||||
turn,
|
||||
});
|
||||
return { ok: true, value };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: normalizeError(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private finish(
|
||||
sessionId: string,
|
||||
status: MainAgentTerminalStatus,
|
||||
turns: number,
|
||||
toolCalls: number,
|
||||
messages: LLMMessage[],
|
||||
finalText?: string
|
||||
): MainAgentRunResult {
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId,
|
||||
status: status === 'completed' ? 'completed' : 'failed',
|
||||
finalResult: {
|
||||
status,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
},
|
||||
error: status === 'completed' ? undefined : { status },
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
sessionId,
|
||||
turns,
|
||||
toolCalls,
|
||||
finalText,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mainAgentRunner = new MainAgentRunner({
|
||||
modelClient: {
|
||||
chat: () => {
|
||||
throw new Error('MainAgentRunner requires an injected model client');
|
||||
},
|
||||
},
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
118
src/agent-kernel/loop/types.ts
Normal file
118
src/agent-kernel/loop/types.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
LLMChatRequest,
|
||||
LLMChatResponse,
|
||||
LLMMessage,
|
||||
LLMToolCall,
|
||||
LLMToolDefinition,
|
||||
} from '../../llm/types';
|
||||
import type {
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentToolCallRecord,
|
||||
CreateAgentSessionInput,
|
||||
} from '../session/types';
|
||||
|
||||
export type MainAgentTerminalStatus =
|
||||
| 'completed'
|
||||
| 'max_turns_reached'
|
||||
| 'max_tool_calls_reached'
|
||||
| 'max_subagents_reached'
|
||||
| 'timeout_reached'
|
||||
| 'max_empty_responses'
|
||||
| 'max_consecutive_tool_failures';
|
||||
|
||||
export interface MainAgentModelClient {
|
||||
chat(request: LLMChatRequest): Promise<LLMChatResponse>;
|
||||
}
|
||||
|
||||
export interface MainAgentToolContext {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
toolCall: LLMToolCall;
|
||||
turn: number;
|
||||
}
|
||||
|
||||
export type ToolPermissionScope =
|
||||
| 'read'
|
||||
| 'write'
|
||||
| 'command'
|
||||
| 'network'
|
||||
| 'git_write'
|
||||
| 'cross_session';
|
||||
|
||||
export type ToolPermissionBehavior = 'allow' | 'deny';
|
||||
|
||||
export interface MainAgentTool {
|
||||
definition: LLMToolDefinition;
|
||||
permissionScope?: ToolPermissionScope;
|
||||
execute(argumentsValue: unknown, context: MainAgentToolContext): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface MainAgentTranscriptRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord;
|
||||
appendMessage(input: {
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}): AgentMessageRecord;
|
||||
appendToolCall(input: {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: 'running' | 'completed' | 'failed';
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentToolCallRecord;
|
||||
completeSession(input: {
|
||||
sessionId: string;
|
||||
status: 'completed' | 'failed' | 'cancelled';
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}): AgentSessionRecord;
|
||||
}
|
||||
|
||||
export interface MainAgentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: MainAgentTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunInput {
|
||||
session?: Omit<CreateAgentSessionInput, 'model'> & { model?: string };
|
||||
sessionId?: string;
|
||||
agentType?: string;
|
||||
model: string;
|
||||
systemPrompt?: string;
|
||||
userMessage: string;
|
||||
temperature?: number;
|
||||
maxTokens?: number;
|
||||
responseFormat?: 'text' | 'json';
|
||||
providerOptions?: Record<string, unknown>;
|
||||
maxTurns: number;
|
||||
maxToolCalls: number;
|
||||
maxSubagents?: number;
|
||||
timeoutMs: number;
|
||||
maxEmptyResponses?: number;
|
||||
maxConsecutiveToolFailures?: number;
|
||||
}
|
||||
|
||||
export interface MainAgentRunResult {
|
||||
status: MainAgentTerminalStatus;
|
||||
sessionId: string;
|
||||
turns: number;
|
||||
toolCalls: number;
|
||||
finalText?: string;
|
||||
messages: LLMMessage[];
|
||||
}
|
||||
|
||||
export interface ToolExecutionResult {
|
||||
ok: boolean;
|
||||
value?: unknown;
|
||||
error?: {
|
||||
name: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
45
src/agent-kernel/model/__tests__/model-resolver.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveAgentModel } from '../model-resolver';
|
||||
|
||||
describe('resolveAgentModel', () => {
|
||||
test('uses spawn override before every configured fallback', () => {
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: 'spawn-model',
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('falls back to AgentDefinition.model when spawn override is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: { model: 'definition-model' },
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('definition-model');
|
||||
});
|
||||
|
||||
test('falls back to defaultSubagentModel when AgentDefinition.model is missing', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
defaultSubagentModel: 'subagent-default-model',
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('subagent-default-model');
|
||||
});
|
||||
|
||||
test('falls back to mainAgentModel when no subagent-specific model exists', () => {
|
||||
const model = resolveAgentModel({
|
||||
agentDefinition: {},
|
||||
mainAgentModel: 'main-model',
|
||||
});
|
||||
|
||||
expect(model).toBe('main-model');
|
||||
});
|
||||
});
|
||||
2
src/agent-kernel/model/index.ts
Normal file
2
src/agent-kernel/model/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { resolveAgentModel } from './model-resolver';
|
||||
export type { AgentModelResolutionInput } from './model-resolver';
|
||||
17
src/agent-kernel/model/model-resolver.ts
Normal file
17
src/agent-kernel/model/model-resolver.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentDefinition } from '../definitions';
|
||||
|
||||
export interface AgentModelResolutionInput {
|
||||
spawnOverride?: string;
|
||||
agentDefinition: Pick<AgentDefinition, 'model'>;
|
||||
defaultSubagentModel?: string;
|
||||
mainAgentModel: string;
|
||||
}
|
||||
|
||||
export function resolveAgentModel(input: AgentModelResolutionInput): string {
|
||||
return (
|
||||
input.spawnOverride ??
|
||||
input.agentDefinition.model ??
|
||||
input.defaultSubagentModel ??
|
||||
input.mainAgentModel
|
||||
);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import type { KernelTaskHandler } from '../types';
|
||||
|
||||
export class KernelTaskRegistry<TState> {
|
||||
private readonly handlers = new Map<string, KernelTaskHandler<TState>>();
|
||||
|
||||
register(handler: KernelTaskHandler<TState>): void {
|
||||
this.handlers.set(handler.name, handler);
|
||||
}
|
||||
|
||||
get(name: string): KernelTaskHandler<TState> | undefined {
|
||||
return this.handlers.get(name);
|
||||
}
|
||||
|
||||
getAll(): KernelTaskHandler<TState>[] {
|
||||
return [...this.handlers.values()];
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { KernelAgentInvoker } from '../agents/kernel-agent-invoker';
|
||||
import { KernelTaskRegistry } from '../registry/kernel-task-registry';
|
||||
import { kernelSessionRepository } from '../session/session-repository';
|
||||
import type {
|
||||
KernelCheckpoint,
|
||||
KernelExecutionContext,
|
||||
KernelTask,
|
||||
KernelTurnPlanner,
|
||||
} from '../types';
|
||||
|
||||
export class AgentKernelRunner<TState> {
|
||||
constructor(
|
||||
private readonly skillRegistry: KernelTaskRegistry<TState>,
|
||||
private readonly subagentInvoker: KernelAgentInvoker<TState>,
|
||||
private readonly planner: KernelTurnPlanner<TState>
|
||||
) {}
|
||||
|
||||
async run(params: {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
initialState: TState;
|
||||
initialTasks: KernelTask[];
|
||||
continueExisting?: boolean;
|
||||
}): Promise<KernelCheckpoint<TState>> {
|
||||
const session = kernelSessionRepository.getSessionById(params.sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Kernel session not found: ${params.sessionId}`);
|
||||
}
|
||||
|
||||
const persisted = kernelSessionRepository.loadCheckpoint<TState>(params.sessionId);
|
||||
let state = persisted?.state ?? params.initialState;
|
||||
const pendingTasks = [...(persisted?.pendingTasks ?? params.initialTasks)];
|
||||
let stopReason: string | undefined;
|
||||
|
||||
while (!stopReason) {
|
||||
if (pendingTasks.length === 0) {
|
||||
const plannedTasks = this.planner.plan({
|
||||
session,
|
||||
runId: params.runId,
|
||||
state,
|
||||
pendingTasks: [...pendingTasks],
|
||||
});
|
||||
|
||||
if (plannedTasks.length === 0) {
|
||||
stopReason = 'completed';
|
||||
break;
|
||||
}
|
||||
|
||||
pendingTasks.push(...plannedTasks);
|
||||
}
|
||||
|
||||
const task = pendingTasks.shift() as KernelTask;
|
||||
if (task.kind === 'subagent' && !this.subagentInvoker.get(task.name)) {
|
||||
throw new Error(`Kernel subagent handler not found: ${task.name}`);
|
||||
}
|
||||
if (task.kind === 'skill' && !this.skillRegistry.get(task.name)) {
|
||||
throw new Error(`Kernel skill handler not found: ${task.name}`);
|
||||
}
|
||||
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_started', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
input: task.input ?? {},
|
||||
runId: params.runId,
|
||||
});
|
||||
|
||||
const context: KernelExecutionContext<TState> = {
|
||||
session,
|
||||
runId: params.runId,
|
||||
state,
|
||||
};
|
||||
let result;
|
||||
let invocation;
|
||||
try {
|
||||
if (task.kind === 'skill') {
|
||||
result = await this.skillRegistry.get(task.name)?.execute(task, context);
|
||||
} else {
|
||||
const invocationOutput = await this.subagentInvoker.invoke(task, context);
|
||||
result = invocationOutput.result;
|
||||
invocation = invocationOutput.invocation;
|
||||
}
|
||||
} catch (error) {
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_failed', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
runId: params.runId,
|
||||
invocationId: invocation?.id,
|
||||
agentId: invocation?.agentId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, {
|
||||
state,
|
||||
pendingTasks: [task, ...pendingTasks],
|
||||
stopReason: 'failed',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (result?.state !== undefined) {
|
||||
state = result.state;
|
||||
}
|
||||
if (result?.prepend?.length) {
|
||||
pendingTasks.unshift(...result.prepend);
|
||||
}
|
||||
if (result?.enqueue?.length) {
|
||||
pendingTasks.push(...result.enqueue);
|
||||
}
|
||||
if (result?.stopReason) {
|
||||
stopReason = result.stopReason;
|
||||
}
|
||||
|
||||
kernelSessionRepository.appendEvent(params.sessionId, 'task_completed', {
|
||||
kind: task.kind,
|
||||
name: task.name,
|
||||
runId: params.runId,
|
||||
invocationId: invocation?.id,
|
||||
agentId: invocation?.agentId,
|
||||
summary: invocation?.result?.summary ?? result?.summary,
|
||||
artifacts: invocation?.result?.artifacts ?? result?.artifacts,
|
||||
stopReason: result?.stopReason,
|
||||
});
|
||||
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, {
|
||||
state,
|
||||
pendingTasks,
|
||||
stopReason,
|
||||
});
|
||||
}
|
||||
|
||||
const checkpoint = {
|
||||
state,
|
||||
pendingTasks,
|
||||
stopReason: stopReason ?? 'completed',
|
||||
};
|
||||
kernelSessionRepository.saveCheckpoint(params.sessionId, checkpoint);
|
||||
return checkpoint;
|
||||
}
|
||||
}
|
||||
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
224
src/agent-kernel/session/__tests__/session-repository.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { closeDatabase, getDatabase, initDatabase } from '../../../db/database';
|
||||
import { agentSessionRepository } from '../session-repository';
|
||||
|
||||
describe('agentSessionRepository', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `agent-session-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
});
|
||||
|
||||
test('migration creates transcript tables and can run idempotently', () => {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT name FROM sqlite_master
|
||||
WHERE type = 'table' AND name IN (
|
||||
'agent_sessions', 'agent_messages', 'agent_tool_calls', 'agent_invocations'
|
||||
)
|
||||
ORDER BY name`
|
||||
)
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
expect(rows.map((row) => row.name)).toEqual([
|
||||
'agent_invocations',
|
||||
'agent_messages',
|
||||
'agent_sessions',
|
||||
'agent_tool_calls',
|
||||
]);
|
||||
|
||||
closeDatabase();
|
||||
initDatabase();
|
||||
|
||||
const migrationRow = getDatabase()
|
||||
.query('SELECT COUNT(*) AS count FROM _migrations WHERE version = 5')
|
||||
.get() as { count: number };
|
||||
expect(migrationRow.count).toBe(1);
|
||||
});
|
||||
|
||||
test('queries parent-child transcript tree in insertion order', () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: { requestId: 'req-1' },
|
||||
});
|
||||
const secondMessage = agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'second' },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'first but inserted second' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
messageId: secondMessage.id,
|
||||
toolName: 'search_code',
|
||||
arguments: { query: 'alpha' },
|
||||
result: { matches: 1 },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: parent.id,
|
||||
toolName: 'read_file',
|
||||
arguments: { path: 'src/index.ts' },
|
||||
result: { content: 'ok' },
|
||||
});
|
||||
|
||||
const firstInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
input: { goal: 'security' },
|
||||
});
|
||||
const secondInvocation = agentSessionRepository.createInvocation({
|
||||
parentSessionId: parent.id,
|
||||
agentType: 'quality-reviewer',
|
||||
model: 'gpt-sub-b',
|
||||
input: { goal: 'quality' },
|
||||
});
|
||||
const child = agentSessionRepository.createSession({
|
||||
parentSessionId: parent.id,
|
||||
parentInvocationId: firstInvocation.id,
|
||||
agentType: 'security-reviewer',
|
||||
model: 'gpt-sub-a',
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: child.id,
|
||||
role: 'assistant',
|
||||
content: { text: 'child transcript' },
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: firstInvocation.id,
|
||||
status: 'completed',
|
||||
result: { summary: 'done' },
|
||||
childSessionId: child.id,
|
||||
});
|
||||
agentSessionRepository.completeInvocation({
|
||||
invocationId: secondInvocation.id,
|
||||
status: 'failed',
|
||||
error: { message: 'boom' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: parent.id,
|
||||
status: 'completed',
|
||||
finalResult: { summary: 'parent done' },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.agentType).toBe('main');
|
||||
expect(tree?.messages.map((message) => message.content)).toEqual([
|
||||
{ text: 'second' },
|
||||
{ text: 'first but inserted second' },
|
||||
]);
|
||||
expect(tree?.toolCalls.map((toolCall) => toolCall.toolName)).toEqual([
|
||||
'search_code',
|
||||
'read_file',
|
||||
]);
|
||||
expect(tree?.invocations.map((invocation) => invocation.agentType)).toEqual([
|
||||
'security-reviewer',
|
||||
'quality-reviewer',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
expect(tree?.invocations[1].error).toEqual({ message: 'boom' });
|
||||
|
||||
const completedTranscript = agentSessionRepository.getInvocationTranscript(firstInvocation.id);
|
||||
expect(completedTranscript?.invocation.id).toBe(firstInvocation.id);
|
||||
expect(completedTranscript?.childSession?.id).toBe(child.id);
|
||||
expect(completedTranscript?.childSession?.messages[0].content).toEqual({
|
||||
text: 'child transcript',
|
||||
});
|
||||
|
||||
const failedTranscript = agentSessionRepository.getInvocationTranscript(secondInvocation.id);
|
||||
expect(failedTranscript?.invocation.id).toBe(secondInvocation.id);
|
||||
expect(failedTranscript?.childSession).toBeUndefined();
|
||||
expect(agentSessionRepository.getInvocationTranscript('missing-invocation')).toBeNull();
|
||||
});
|
||||
|
||||
test('redacts sensitive JSON fields before storage', () => {
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'gpt-main',
|
||||
metadata: {
|
||||
apiKey: 'sk-live',
|
||||
nested: { authorization: 'Bearer token', safe: 'visible' },
|
||||
},
|
||||
});
|
||||
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: session.id,
|
||||
role: 'user',
|
||||
content: { password: 'p4ss', text: 'keep me' },
|
||||
});
|
||||
agentSessionRepository.appendToolCall({
|
||||
sessionId: session.id,
|
||||
toolName: 'call_provider',
|
||||
arguments: { token: 'tok_123', payload: { secret: 'hidden', value: 1 } },
|
||||
result: { ok: true, refreshToken: 'refresh_123' },
|
||||
});
|
||||
agentSessionRepository.completeSession({
|
||||
sessionId: session.id,
|
||||
status: 'failed',
|
||||
error: { message: 'bad', credentials: { api_key: 'secret-key' } },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(session.id);
|
||||
expect(tree?.metadata).toEqual({
|
||||
apiKey: '[REDACTED]',
|
||||
nested: { authorization: '[REDACTED]', safe: 'visible' },
|
||||
});
|
||||
expect(tree?.messages[0].content).toEqual({ password: '[REDACTED]', text: 'keep me' });
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
token: '[REDACTED]',
|
||||
payload: { secret: '[REDACTED]', value: 1 },
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({ ok: true, refreshToken: '[REDACTED]' });
|
||||
expect(tree?.error).toEqual({
|
||||
message: 'bad',
|
||||
credentials: '[REDACTED]',
|
||||
});
|
||||
});
|
||||
|
||||
test('getSessionTreeByRunId finds the correct session tree by reviewRunId', () => {
|
||||
const runId = 'test-run-123';
|
||||
const session = agentSessionRepository.createSession({
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
metadata: { reviewRunId: runId },
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
expect(tree).not.toBeNull();
|
||||
expect(tree?.id).toBe(session.id);
|
||||
expect(tree?.metadata.reviewRunId).toBe(runId);
|
||||
|
||||
const missingTree = agentSessionRepository.getSessionTreeByRunId('missing-run');
|
||||
expect(missingTree).toBeNull();
|
||||
});
|
||||
});
|
||||
18
src/agent-kernel/session/index.ts
Normal file
18
src/agent-kernel/session/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { agentSessionRepository, AgentSessionRepository } from './session-repository';
|
||||
export { redactSensitiveFields } from './redaction';
|
||||
export type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
36
src/agent-kernel/session/redaction.ts
Normal file
36
src/agent-kernel/session/redaction.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const REDACTED_VALUE = '[REDACTED]';
|
||||
|
||||
const SENSITIVE_KEY_PARTS = [
|
||||
'apikey',
|
||||
'api_key',
|
||||
'authorization',
|
||||
'auth_token',
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'token',
|
||||
'password',
|
||||
'passwd',
|
||||
'secret',
|
||||
'credential',
|
||||
];
|
||||
|
||||
function isSensitiveKey(key: string): boolean {
|
||||
const normalized = key.replace(/[-\s]/g, '_').toLowerCase();
|
||||
return SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part));
|
||||
}
|
||||
|
||||
export function redactSensitiveFields<T>(value: T): T {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => redactSensitiveFields(item)) as T;
|
||||
}
|
||||
|
||||
if (!value || typeof value !== 'object') {
|
||||
return value;
|
||||
}
|
||||
|
||||
const redacted: Record<string, unknown> = {};
|
||||
for (const [key, childValue] of Object.entries(value as Record<string, unknown>)) {
|
||||
redacted[key] = isSensitiveKey(key) ? REDACTED_VALUE : redactSensitiveFields(childValue);
|
||||
}
|
||||
return redacted as T;
|
||||
}
|
||||
@@ -1,335 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import { redactSensitiveFields } from './redaction';
|
||||
import type {
|
||||
KernelCheckpoint,
|
||||
KernelDelegationPacket,
|
||||
KernelSessionEventRecord,
|
||||
KernelSessionRecord,
|
||||
KernelSubagentInvocationRecord,
|
||||
KernelSubagentInvocationResult,
|
||||
} from '../types';
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
|
||||
interface SessionRow {
|
||||
interface AgentSessionRow {
|
||||
id: string;
|
||||
scope_type: 'pull_request' | 'commit';
|
||||
scope_key: string;
|
||||
parent_session_id: string | null;
|
||||
parent_invocation_id: string | null;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata_json: string;
|
||||
final_result_json: string | null;
|
||||
error_json: string | null;
|
||||
started_at: string;
|
||||
completed_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface AgentMessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content_json: string;
|
||||
metadata_json: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_run_id?: string;
|
||||
}
|
||||
|
||||
interface EventRow {
|
||||
interface AgentToolCallRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
event_type: string;
|
||||
payload_json: string;
|
||||
message_id: string | null;
|
||||
sequence: number;
|
||||
tool_name: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
interface CheckpointRow {
|
||||
session_id: string;
|
||||
state_json: string;
|
||||
pending_tasks_json: string;
|
||||
stop_reason?: string;
|
||||
updated_at: string;
|
||||
state_version: number;
|
||||
}
|
||||
|
||||
interface SubagentInvocationRow {
|
||||
interface AgentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
parent_run_id: string;
|
||||
parent_task_name: string;
|
||||
subagent_name: string;
|
||||
agent_id: string;
|
||||
status: 'running' | 'completed' | 'failed';
|
||||
child_session_id: string | null;
|
||||
sequence: number;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input_json: string;
|
||||
result_json?: string;
|
||||
started_at: string;
|
||||
finished_at?: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: SessionRow): KernelSessionRecord {
|
||||
function stringifyJson(value: unknown): string {
|
||||
return JSON.stringify(redactSensitiveFields(value));
|
||||
}
|
||||
|
||||
function parseJson(value: string | null): unknown | undefined {
|
||||
return value === null ? undefined : JSON.parse(value);
|
||||
}
|
||||
|
||||
function nextSequence(tableName: string, ownerColumn: string, ownerId: string): number {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT COALESCE(MAX(sequence), 0) + 1 AS next_sequence FROM ${tableName} WHERE ${ownerColumn} = ?`
|
||||
)
|
||||
.get(ownerId) as { next_sequence: number };
|
||||
return row.next_sequence;
|
||||
}
|
||||
|
||||
function toSessionRecord(row: AgentSessionRow): AgentSessionRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
scopeType: row.scope_type,
|
||||
scopeKey: row.scope_key,
|
||||
parentSessionId: row.parent_session_id ?? undefined,
|
||||
parentInvocationId: row.parent_invocation_id ?? undefined,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
finalResult: parseJson(row.final_result_json),
|
||||
error: parseJson(row.error_json),
|
||||
startedAt: row.started_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
lastRunId: row.last_run_id,
|
||||
};
|
||||
}
|
||||
|
||||
export class KernelSessionRepository {
|
||||
ensureSession(input: {
|
||||
scopeType: 'pull_request' | 'commit';
|
||||
scopeKey: string;
|
||||
metadata: Record<string, unknown>;
|
||||
runId?: string;
|
||||
}): KernelSessionRecord {
|
||||
function toMessageRecord(row: AgentMessageRow): AgentMessageRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
sequence: row.sequence,
|
||||
role: row.role,
|
||||
content: JSON.parse(row.content_json),
|
||||
metadata: JSON.parse(row.metadata_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toToolCallRecord(row: AgentToolCallRow): AgentToolCallRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
messageId: row.message_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
toolName: row.tool_name,
|
||||
status: row.status,
|
||||
arguments: JSON.parse(row.arguments_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function toInvocationRecord(row: AgentInvocationRow): AgentInvocationRecord {
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
childSessionId: row.child_session_id ?? undefined,
|
||||
sequence: row.sequence,
|
||||
agentType: row.agent_type,
|
||||
model: row.model,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json),
|
||||
result: parseJson(row.result_json),
|
||||
error: parseJson(row.error_json),
|
||||
createdAt: row.created_at,
|
||||
completedAt: row.completed_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export class AgentSessionRepository {
|
||||
createSession(input: CreateAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
const existing = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE scope_key = ?`
|
||||
)
|
||||
.get(input.scopeKey) as SessionRow | null;
|
||||
|
||||
if (existing) {
|
||||
db.query(
|
||||
`UPDATE agent_kernel_sessions
|
||||
SET metadata_json = ?, updated_at = datetime('now'), last_run_id = ?
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
JSON.stringify(input.metadata),
|
||||
input.runId ?? existing.last_run_id ?? null,
|
||||
existing.id
|
||||
);
|
||||
|
||||
return this.getSessionById(existing.id) as KernelSessionRecord;
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const id = input.id ?? randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_sessions (
|
||||
id, scope_type, scope_key, metadata_json, last_run_id
|
||||
) VALUES (?, ?, ?, ?, ?)`
|
||||
).run(id, input.scopeType, input.scopeKey, JSON.stringify(input.metadata), input.runId ?? null);
|
||||
|
||||
return this.getSessionById(id) as KernelSessionRecord;
|
||||
}
|
||||
|
||||
getSessionById(sessionId: string): KernelSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(sessionId) as SessionRow | null;
|
||||
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
getSessionByScopeKey(scopeKey: string): KernelSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
WHERE scope_key = ?`
|
||||
)
|
||||
.get(scopeKey) as SessionRow | null;
|
||||
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
listSessions(limit = 50): KernelSessionRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, scope_type, scope_key, metadata_json, created_at, updated_at, last_run_id
|
||||
FROM agent_kernel_sessions
|
||||
ORDER BY updated_at DESC, created_at DESC
|
||||
LIMIT ?`
|
||||
)
|
||||
.all(limit) as SessionRow[];
|
||||
|
||||
return rows.map(toSessionRecord);
|
||||
}
|
||||
|
||||
appendEvent(
|
||||
sessionId: string,
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>
|
||||
): KernelSessionEventRecord {
|
||||
const db = getDatabase();
|
||||
const id = randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_session_events (id, session_id, event_type, payload_json)
|
||||
VALUES (?, ?, ?, ?)`
|
||||
).run(id, sessionId, eventType, JSON.stringify(payload));
|
||||
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, session_id, event_type, payload_json, created_at
|
||||
FROM agent_kernel_session_events
|
||||
WHERE id = ?`
|
||||
)
|
||||
.get(id) as EventRow;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
listEvents(sessionId: string): KernelSessionEventRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, session_id, event_type, payload_json, created_at
|
||||
FROM agent_kernel_session_events
|
||||
WHERE session_id = ?
|
||||
ORDER BY created_at ASC, id ASC`
|
||||
)
|
||||
.all(sessionId) as EventRow[];
|
||||
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
sessionId: row.session_id,
|
||||
eventType: row.event_type,
|
||||
payload: JSON.parse(row.payload_json) as Record<string, unknown>,
|
||||
createdAt: row.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
saveCheckpoint<TState>(
|
||||
sessionId: string,
|
||||
checkpoint: KernelCheckpoint<TState>,
|
||||
stateVersion = 1
|
||||
): void {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_session_checkpoints (
|
||||
session_id, state_json, pending_tasks_json, stop_reason, state_version, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
pending_tasks_json = excluded.pending_tasks_json,
|
||||
stop_reason = excluded.stop_reason,
|
||||
state_version = excluded.state_version,
|
||||
updated_at = datetime('now')`
|
||||
`INSERT INTO agent_sessions (
|
||||
id, parent_session_id, parent_invocation_id, agent_type, model, status, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
sessionId,
|
||||
JSON.stringify(checkpoint.state),
|
||||
JSON.stringify(checkpoint.pendingTasks),
|
||||
checkpoint.stopReason ?? null,
|
||||
stateVersion
|
||||
id,
|
||||
input.parentSessionId ?? null,
|
||||
input.parentInvocationId ?? null,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
|
||||
const session = this.getSession(id);
|
||||
if (!session) throw new Error('Failed to load created agent session');
|
||||
return session;
|
||||
}
|
||||
|
||||
loadCheckpoint<TState>(sessionId: string): KernelCheckpoint<TState> | null {
|
||||
getSession(sessionId: string): AgentSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT session_id, state_json, pending_tasks_json, stop_reason, updated_at, state_version
|
||||
FROM agent_kernel_session_checkpoints
|
||||
WHERE session_id = ?`
|
||||
)
|
||||
.get(sessionId) as CheckpointRow | null;
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
state: JSON.parse(row.state_json) as TState,
|
||||
pendingTasks: JSON.parse(row.pending_tasks_json) as KernelCheckpoint<TState>['pendingTasks'],
|
||||
stopReason: row.stop_reason,
|
||||
};
|
||||
.query('SELECT * FROM agent_sessions WHERE id = ?')
|
||||
.get(sessionId) as AgentSessionRow | null;
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
deleteCheckpoint(sessionId: string): void {
|
||||
appendMessage(input: AppendAgentMessageInput): AgentMessageRecord {
|
||||
const db = getDatabase();
|
||||
db.query('DELETE FROM agent_kernel_session_checkpoints WHERE session_id = ?').run(sessionId);
|
||||
}
|
||||
|
||||
createSubagentInvocation(input: {
|
||||
parentSessionId: string;
|
||||
parentRunId: string;
|
||||
parentTaskName: string;
|
||||
subagentName: string;
|
||||
agentId: string;
|
||||
packet: KernelDelegationPacket;
|
||||
}): KernelSubagentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = randomUUID();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_messages', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_kernel_subagent_invocations (
|
||||
id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`
|
||||
`INSERT INTO agent_messages (id, session_id, sequence, role, content_json, metadata_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
sequence,
|
||||
input.role,
|
||||
stringifyJson(input.content),
|
||||
stringifyJson(input.metadata ?? {})
|
||||
);
|
||||
return this.getMessage(id) as AgentMessageRecord;
|
||||
}
|
||||
|
||||
appendToolCall(input: AppendAgentToolCallInput): AgentToolCallRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const status = input.status ?? 'completed';
|
||||
const sequence = nextSequence('agent_tool_calls', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_tool_calls (
|
||||
id, session_id, message_id, sequence, tool_name, status, arguments_json, result_json, error_json, completed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.sessionId,
|
||||
input.messageId ?? null,
|
||||
sequence,
|
||||
input.toolName,
|
||||
status,
|
||||
stringifyJson(input.arguments ?? {}),
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
status === 'running' ? null : new Date().toISOString()
|
||||
);
|
||||
return this.getToolCall(id) as AgentToolCallRecord;
|
||||
}
|
||||
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_invocations', 'parent_session_id', input.parentSessionId);
|
||||
db.query(
|
||||
`INSERT INTO agent_invocations (
|
||||
id, parent_session_id, child_session_id, sequence, agent_type, model, status, input_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
id,
|
||||
input.parentSessionId,
|
||||
input.parentRunId,
|
||||
input.parentTaskName,
|
||||
input.subagentName,
|
||||
input.agentId,
|
||||
JSON.stringify(input.packet)
|
||||
input.childSessionId ?? null,
|
||||
sequence,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.input ?? {})
|
||||
);
|
||||
|
||||
return this.getSubagentInvocationById(id) as KernelSubagentInvocationRecord;
|
||||
return this.getInvocation(id) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSubagentInvocation(
|
||||
invocationId: string,
|
||||
status: 'completed' | 'failed',
|
||||
result: KernelSubagentInvocationResult
|
||||
): KernelSubagentInvocationRecord {
|
||||
completeSession(input: CompleteAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_kernel_subagent_invocations
|
||||
SET status = ?, result_json = ?, finished_at = datetime('now')
|
||||
`UPDATE agent_sessions
|
||||
SET status = ?, final_result_json = ?, error_json = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(status, JSON.stringify(result), invocationId);
|
||||
|
||||
return this.getSubagentInvocationById(invocationId) as KernelSubagentInvocationRecord;
|
||||
).run(
|
||||
input.status,
|
||||
input.finalResult === undefined ? null : stringifyJson(input.finalResult),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.sessionId
|
||||
);
|
||||
return this.getSession(input.sessionId) as AgentSessionRecord;
|
||||
}
|
||||
|
||||
listSubagentInvocations(parentSessionId: string): KernelSubagentInvocationRecord[] {
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query(
|
||||
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
|
||||
status, input_json, result_json, started_at, finished_at
|
||||
FROM agent_kernel_subagent_invocations
|
||||
WHERE parent_session_id = ?
|
||||
ORDER BY started_at ASC, id ASC`
|
||||
)
|
||||
.all(parentSessionId) as SubagentInvocationRow[];
|
||||
|
||||
return rows.map((row) => this.toSubagentInvocationRecord(row));
|
||||
db.query(
|
||||
`UPDATE agent_invocations
|
||||
SET status = ?, child_session_id = COALESCE(?, child_session_id), result_json = ?, error_json = ?, completed_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).run(
|
||||
input.status,
|
||||
input.childSessionId ?? null,
|
||||
input.result === undefined ? null : stringifyJson(input.result),
|
||||
input.error === undefined ? null : stringifyJson(input.error),
|
||||
input.invocationId
|
||||
);
|
||||
return this.getInvocation(input.invocationId) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
private getSubagentInvocationById(invocationId: string): KernelSubagentInvocationRecord | null {
|
||||
getSessionTree(rootSessionId: string): AgentSessionTree | null {
|
||||
const session = this.getSession(rootSessionId);
|
||||
if (!session) return null;
|
||||
|
||||
const invocations = this.listInvocations(rootSessionId).map((invocation) => ({
|
||||
...invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
}));
|
||||
|
||||
return {
|
||||
...session,
|
||||
messages: this.listMessages(rootSessionId),
|
||||
toolCalls: this.listToolCalls(rootSessionId),
|
||||
invocations,
|
||||
};
|
||||
}
|
||||
|
||||
getSessionTreeByRunId(runId: string): AgentSessionTree | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query(
|
||||
`SELECT id, parent_session_id, parent_run_id, parent_task_name, subagent_name, agent_id,
|
||||
status, input_json, result_json, started_at, finished_at
|
||||
FROM agent_kernel_subagent_invocations
|
||||
WHERE id = ?`
|
||||
`SELECT id FROM agent_sessions
|
||||
WHERE parent_session_id IS NULL
|
||||
AND json_extract(metadata_json, '$.reviewRunId') = ?`
|
||||
)
|
||||
.get(invocationId) as SubagentInvocationRow | null;
|
||||
.get(runId) as { id: string } | null;
|
||||
|
||||
return row ? this.toSubagentInvocationRecord(row) : null;
|
||||
if (!row) return null;
|
||||
return this.getSessionTree(row.id);
|
||||
}
|
||||
|
||||
private toSubagentInvocationRecord(row: SubagentInvocationRow): KernelSubagentInvocationRecord {
|
||||
listMessages(sessionId: string): AgentMessageRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_messages WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentMessageRow[];
|
||||
return rows.map(toMessageRecord);
|
||||
}
|
||||
|
||||
listToolCalls(sessionId: string): AgentToolCallRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE session_id = ? ORDER BY sequence ASC')
|
||||
.all(sessionId) as AgentToolCallRow[];
|
||||
return rows.map(toToolCallRecord);
|
||||
}
|
||||
|
||||
listInvocations(parentSessionId: string): AgentInvocationRecord[] {
|
||||
const db = getDatabase();
|
||||
const rows = db
|
||||
.query('SELECT * FROM agent_invocations WHERE parent_session_id = ? ORDER BY sequence ASC')
|
||||
.all(parentSessionId) as AgentInvocationRow[];
|
||||
return rows.map(toInvocationRecord);
|
||||
}
|
||||
|
||||
getInvocationTranscript(invocationId: string): AgentInvocationTranscript | null {
|
||||
const invocation = this.getInvocation(invocationId);
|
||||
if (!invocation) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
parentSessionId: row.parent_session_id,
|
||||
parentRunId: row.parent_run_id,
|
||||
parentTaskName: row.parent_task_name,
|
||||
subagentName: row.subagent_name,
|
||||
agentId: row.agent_id,
|
||||
status: row.status,
|
||||
input: JSON.parse(row.input_json) as KernelDelegationPacket,
|
||||
result: row.result_json
|
||||
? (JSON.parse(row.result_json) as KernelSubagentInvocationResult)
|
||||
invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at,
|
||||
};
|
||||
}
|
||||
|
||||
private getMessage(messageId: string): AgentMessageRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_messages WHERE id = ?')
|
||||
.get(messageId) as AgentMessageRow | null;
|
||||
return row ? toMessageRecord(row) : null;
|
||||
}
|
||||
|
||||
private getToolCall(toolCallId: string): AgentToolCallRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_tool_calls WHERE id = ?')
|
||||
.get(toolCallId) as AgentToolCallRow | null;
|
||||
return row ? toToolCallRecord(row) : null;
|
||||
}
|
||||
|
||||
private getInvocation(invocationId: string): AgentInvocationRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_invocations WHERE id = ?')
|
||||
.get(invocationId) as AgentInvocationRow | null;
|
||||
return row ? toInvocationRecord(row) : null;
|
||||
}
|
||||
}
|
||||
|
||||
export const kernelSessionRepository = new KernelSessionRepository();
|
||||
export const agentSessionRepository = new AgentSessionRepository();
|
||||
|
||||
122
src/agent-kernel/session/types.ts
Normal file
122
src/agent-kernel/session/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
export type AgentSessionStatus = 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
export type AgentToolCallStatus = 'running' | 'completed' | 'failed';
|
||||
|
||||
export interface CreateAgentSessionInput {
|
||||
id?: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentSessionRecord {
|
||||
id: string;
|
||||
parentSessionId?: string;
|
||||
parentInvocationId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
metadata: Record<string, unknown>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
startedAt: string;
|
||||
completedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentMessageInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: unknown;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AppendAgentToolCallInput {
|
||||
id?: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
toolName: string;
|
||||
status?: AgentToolCallStatus;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentToolCallRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
sequence: number;
|
||||
toolName: string;
|
||||
status: AgentToolCallStatus;
|
||||
arguments: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentInvocationInput {
|
||||
id?: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status?: AgentSessionStatus;
|
||||
input?: unknown;
|
||||
}
|
||||
|
||||
export interface AgentInvocationRecord {
|
||||
id: string;
|
||||
parentSessionId: string;
|
||||
childSessionId?: string;
|
||||
sequence: number;
|
||||
agentType: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface CompleteAgentSessionInput {
|
||||
sessionId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
finalResult?: unknown;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
export interface CompleteAgentInvocationInput {
|
||||
invocationId: string;
|
||||
status: Exclude<AgentSessionStatus, 'running'>;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
childSessionId?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionTree extends AgentSessionRecord {
|
||||
messages: AgentMessageRecord[];
|
||||
toolCalls: AgentToolCallRecord[];
|
||||
invocations: Array<AgentInvocationRecord & { childSession?: AgentSessionTree }>;
|
||||
}
|
||||
|
||||
export interface AgentInvocationTranscript {
|
||||
invocation: AgentInvocationRecord;
|
||||
childSession?: AgentSessionTree;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user