mirror of
https://github.com/jeffusion/gitea-ai-assistant.git
synced 2026-06-12 23:16:49 +00:00
Compare commits
7 Commits
refactor/d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d0ad6bf20 | ||
|
|
a9c70ab292 | ||
|
|
635eb7a88f | ||
|
|
2ee9f570c4 | ||
|
|
27f4ac6a18 | ||
|
|
44d52cddc5 | ||
|
|
1e38a0e5e0 |
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=="],
|
||||
|
||||
|
||||
@@ -46,7 +46,10 @@ services:
|
||||
- NODE_ENV=production
|
||||
- GITEA_API_URL=http://gitea:3000/api/v1
|
||||
- GITEA_ACCESS_TOKEN=${E2E_GITEA_TOKEN:-placeholder}
|
||||
- E2E_MOCK_LLM=1
|
||||
- PORT=5174
|
||||
- ENCRYPTION_KEY=${E2E_ENCRYPTION_KEY:-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef}
|
||||
- WEBHOOK_SECRET=e2e-test-secret
|
||||
ports:
|
||||
- "3334:5174"
|
||||
healthcheck:
|
||||
|
||||
@@ -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,20 +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)
|
||||
- [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,24 +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)
|
||||
- [通知服务重构设计](./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,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 评论发布)
|
||||
- 行级问题(含置信度与严重性,作为审查评论发布)
|
||||
|
||||
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。
|
||||
20
e2e/seed.sh
20
e2e/seed.sh
@@ -26,12 +26,12 @@ for i in $(seq 1 30); do
|
||||
done
|
||||
|
||||
echo "=== [2/6] 创建管理员用户 ==="
|
||||
docker exec 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" \
|
||||
@@ -129,7 +129,7 @@ for i in $(seq 1 20); do
|
||||
done
|
||||
|
||||
# Login to get JWT
|
||||
LOGIN_RESP=$(curl -sf -X POST "${ASSISTANT_URL}/admin/login" \
|
||||
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)
|
||||
@@ -138,7 +138,7 @@ if [ -z "${ADMIN_JWT}" ]; then
|
||||
echo " WARNING: 无法获取管理员 JWT,跳过 assistant 配置"
|
||||
else
|
||||
echo " JWT 获取成功,配置 assistant 设置..."
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/config" \
|
||||
curl -sf -X PUT "${ASSISTANT_URL}/admin/api/config" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${ADMIN_JWT}" \
|
||||
-d "{
|
||||
@@ -146,10 +146,8 @@ else
|
||||
\"GITEA_API_URL\": \"http://gitea:3000/api/v1\",
|
||||
\"REVIEW_ENGINE\": \"agent\",
|
||||
\"REVIEW_WORKDIR\": \"/tmp/e2e-review\",
|
||||
\"REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE\": \"0.5\",
|
||||
\"REVIEW_ENABLE_HUMAN_GATE\": \"false\",
|
||||
\"REVIEW_ALLOWED_COMMANDS\": \"git,rg,cat,sed,wc\",
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"30000\"
|
||||
\"REVIEW_COMMAND_TIMEOUT_MS\": \"120000\"
|
||||
}" > /dev/null 2>&1 && echo " Assistant 配置完成" || echo " WARNING: assistant 配置失败"
|
||||
fi
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
@@ -6,6 +6,7 @@ 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';
|
||||
@@ -54,6 +55,7 @@ function AppContent() {
|
||||
<Route path="config" element={<ConfigManager />} />
|
||||
<Route path="notifications" element={<NotificationConfigPage />} />
|
||||
<Route path="review-config" element={<ReviewConfigPage />} />
|
||||
<Route path="review-runs" element={<ReviewSessionsPage />} />
|
||||
<Route path="*" element={<Navigate to="/repos" replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -32,14 +32,11 @@ const AGENT_SHARED_FIELDS = new Set([
|
||||
|
||||
/** Fields specific to agent mode only. */
|
||||
const AGENT_ONLY_FIELDS = new Set([
|
||||
'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
'REVIEW_ENABLE_HUMAN_GATE',
|
||||
'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. */
|
||||
@@ -102,16 +99,13 @@ export function ReviewConfigPage() {
|
||||
return 'agent';
|
||||
}, [localConfig]);
|
||||
|
||||
// Derived: review group and memory group from fetched data
|
||||
const reviewGroup = useMemo(() => data?.groups.find((g) => g.key === 'review'), [data]);
|
||||
const memoryGroup = useMemo(() => data?.groups.find((g) => g.key === 'memory'), [data]);
|
||||
|
||||
// Initialize local config from ALL groups (so save works for review + memory fields)
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
const initialState: Record<string, any> = {};
|
||||
data.groups
|
||||
.filter((g) => g.key === 'review' || g.key === 'memory')
|
||||
.filter((g) => g.key === 'review')
|
||||
.forEach((group) => {
|
||||
group.fields.forEach((field) => {
|
||||
if (field.sensitive && field.hasValue) {
|
||||
@@ -158,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 {
|
||||
@@ -175,7 +172,7 @@ export function ReviewConfigPage() {
|
||||
};
|
||||
|
||||
const handleResetAll = () => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const allOverrideKeys = groups
|
||||
.flatMap((g) => g.fields)
|
||||
.filter((f) => f.source === 'db')
|
||||
@@ -193,9 +190,9 @@ export function ReviewConfigPage() {
|
||||
);
|
||||
|
||||
const hasOverrides = useMemo(() => {
|
||||
const groups = [reviewGroup, memoryGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
const groups = [reviewGroup].filter(Boolean) as ConfigGroupDto[];
|
||||
return groups.some((g) => g.fields.some((f) => f.source === 'db'));
|
||||
}, [reviewGroup, memoryGroup]);
|
||||
}, [reviewGroup]);
|
||||
|
||||
// -- Render states --
|
||||
|
||||
@@ -229,7 +226,7 @@ export function ReviewConfigPage() {
|
||||
description:
|
||||
engine === 'codex'
|
||||
? 'Codex CLI 审查引擎配置'
|
||||
: '多代理编排审查引擎配置',
|
||||
: 'Agent 审查引擎配置',
|
||||
fields: visibleReviewFields,
|
||||
}
|
||||
: null;
|
||||
@@ -358,17 +355,6 @@ export function ReviewConfigPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory group — agent mode only */}
|
||||
{engine === 'agent' && memoryGroup && (
|
||||
<ConfigGroupCard
|
||||
group={memoryGroup}
|
||||
localConfig={localConfig}
|
||||
onFieldChange={handleFieldChange}
|
||||
onReset={handleResetGroup}
|
||||
isResetting={resetMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{engine !== 'codex' && (
|
||||
<>
|
||||
<ProviderList />
|
||||
|
||||
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,213 +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 { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { AlertCircle, Save, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Save, ShieldCheck } from 'lucide-react';
|
||||
import { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { ModelCombobox } from './ModelCombobox';
|
||||
|
||||
const ROLE_LABELS: Record<string, { label: string; desc: string }> = {
|
||||
planner: { label: '规划器 Planner', desc: '多阶段审查的第一步,负责分析上下文并分配任务' },
|
||||
specialist: { label: '专家 Specialist', desc: '执行深度代码审查的主力模型,专注于发现具体问题' },
|
||||
judge: { label: '评审 Judge', desc: '对专家的建议进行审核、合并和过滤,确保评论质量' },
|
||||
embedding: { label: '嵌入 Embedding', desc: '用于向量化代码和注释,支持语义搜索 (Qdrant)' },
|
||||
};
|
||||
|
||||
const ROLES = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
|
||||
interface RoleState {
|
||||
providerId: string | null;
|
||||
model: string;
|
||||
}
|
||||
|
||||
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 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 || '',
|
||||
};
|
||||
});
|
||||
// Fill missing roles
|
||||
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">
|
||||
角色分配
|
||||
</CardTitle>
|
||||
<CardDescription className="text-muted-foreground">
|
||||
为 AI 审查系统的不同角色指定提供商和模型
|
||||
</CardDescription>
|
||||
<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-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>
|
||||
</div>
|
||||
</CardHeader>
|
||||
)}
|
||||
|
||||
<CardContent className="theme-card-content">
|
||||
{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;
|
||||
|
||||
return (
|
||||
<div key={role} className="flex flex-col md:flex-row items-start md:items-center gap-4 py-5 px-1 hover:bg-accent/40 transition-colors rounded-lg">
|
||||
<div className="w-full md:w-1/3 space-y-1.5">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{ROLE_LABELS[role]?.label || role}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{ROLE_LABELS[role]?.desc}
|
||||
</p>
|
||||
<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';
|
||||
|
||||
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>
|
||||
<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 className="w-full md:w-2/3 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 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>
|
||||
);
|
||||
})}
|
||||
</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,7 +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 { fetchProviders, fetchRoles, setRole } from '@/services/llmProviderService';
|
||||
import { fetchConfig, updateConfig, type ConfigResponse } from '@/services/configService';
|
||||
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
@@ -13,21 +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(),
|
||||
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({
|
||||
@@ -39,60 +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 role cards and supports provider/model editing', 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(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('角色分配')).toBeInTheDocument();
|
||||
expect(await screen.findByText('规划器 Planner')).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();
|
||||
|
||||
// Radix Select renders placeholder in a span with pointer-events: none.
|
||||
// Click the trigger button (parent) instead of the placeholder text.
|
||||
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,7 +1,7 @@
|
||||
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 } 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';
|
||||
@@ -11,6 +11,7 @@ const navItems = [
|
||||
{ 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() {
|
||||
|
||||
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
797
frontend/src/pages/ReviewSessionsPage.tsx
Normal file
@@ -0,0 +1,797 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { fetchReviewRuns, fetchReviewRunDetails } from '@/services/reviewSessionService';
|
||||
import type { AgentSessionTree } from '@/services/reviewSessionService';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Bot, Cpu, Terminal, CheckCircle2, AlertCircle,
|
||||
ChevronRight, ChevronDown, Clock, FileText, Layers,
|
||||
AlertTriangle, CornerDownRight, HelpCircle, Info
|
||||
} from 'lucide-react';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper Components & Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
switch (status) {
|
||||
case 'succeeded':
|
||||
case 'completed':
|
||||
return <Badge className="bg-success/20 text-success border-success/30">成功</Badge>;
|
||||
case 'failed':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30">失败</Badge>;
|
||||
case 'running':
|
||||
case 'in_progress':
|
||||
return <Badge className="bg-primary/20 text-primary border-primary/30 animate-pulse">运行中</Badge>;
|
||||
case 'queued':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30">排队中</Badge>;
|
||||
case 'ignored':
|
||||
return <Badge className="bg-muted text-muted-foreground border-border">已忽略</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{status}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function SeverityBadge({ severity }: { severity: 'high' | 'medium' | 'low' }) {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return <Badge className="bg-danger/20 text-danger border-danger/30 font-bold">高</Badge>;
|
||||
case 'medium':
|
||||
return <Badge className="bg-warning/20 text-warning border-warning/30 font-bold">中</Badge>;
|
||||
case 'low':
|
||||
return <Badge className="bg-info/20 text-info border-info/30 font-bold">低</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">{severity}</Badge>;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(isoString?: string): string {
|
||||
if (!isoString) return '-';
|
||||
return new Date(isoString).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Tree Node Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface TreeNodeProps {
|
||||
session: AgentSessionTree;
|
||||
level: number;
|
||||
onSelectSession: (session: AgentSessionTree) => void;
|
||||
selectedSessionId?: string;
|
||||
}
|
||||
|
||||
function AgentTreeNode({ session, level, onSelectSession, selectedSessionId }: TreeNodeProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const hasChildren = session.invocations && session.invocations.some(inv => inv.childSession);
|
||||
const isSelected = selectedSessionId === session.id;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Node Row */}
|
||||
<div
|
||||
onClick={() => onSelectSession(session)}
|
||||
className={`flex items-center justify-between p-3 rounded-xl border transition-all duration-200 cursor-pointer mb-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/10 theme-glow-primary'
|
||||
: 'border-border/60 bg-muted/30 hover:bg-accent/40 hover:border-border'
|
||||
}`}
|
||||
style={{ marginLeft: `${level * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
}}
|
||||
className="p-1 rounded-md hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
{level > 0 && <CornerDownRight className="w-4 h-4 text-muted-foreground/50" />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-2 rounded-lg ${level === 0 ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info'}`}>
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-foreground truncate">
|
||||
{level === 0 ? '主代理' : '子代理'}: {session.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground font-mono truncate">
|
||||
{session.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={session.status} />
|
||||
{session.error && (
|
||||
<div title="代理执行出错">
|
||||
<AlertTriangle className="w-4 h-4 text-danger animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Children */}
|
||||
{isExpanded && session.invocations && (
|
||||
<div className="flex flex-col w-full">
|
||||
{session.invocations.map((inv) => {
|
||||
if (inv.childSession) {
|
||||
return (
|
||||
<AgentTreeNode
|
||||
key={inv.childSession.id}
|
||||
session={inv.childSession}
|
||||
level={level + 1}
|
||||
onSelectSession={onSelectSession}
|
||||
selectedSessionId={selectedSessionId}
|
||||
/>
|
||||
);
|
||||
} else if (inv.status === 'failed') {
|
||||
// Failed subagent invocation without child session
|
||||
return (
|
||||
<div
|
||||
key={inv.id}
|
||||
className="flex items-center justify-between p-3 rounded-xl border border-danger/30 bg-danger/5 mb-2"
|
||||
style={{ marginLeft: `${(level + 1) * 24}px` }}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-6 h-6 flex items-center justify-center">
|
||||
<CornerDownRight className="w-4 h-4 text-danger/50" />
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-danger/10 text-danger">
|
||||
<Bot className="w-4 h-4" />
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="font-semibold text-sm text-danger truncate">
|
||||
子代理启动失败: {inv.agentType}
|
||||
</span>
|
||||
<span className="text-xs text-danger/80 font-mono truncate">
|
||||
{inv.model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status="failed" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent Session Detail Panel Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DetailPanelProps {
|
||||
session: AgentSessionTree;
|
||||
}
|
||||
|
||||
function AgentDetailPanel({ session }: DetailPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<'messages' | 'tools' | 'raw'>('messages');
|
||||
|
||||
return (
|
||||
<Card className="border-border/60 bg-muted/10 h-full flex flex-col">
|
||||
<CardHeader className="border-b border-border/50 pb-4 shrink-0">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Cpu className="w-5 h-5 text-primary" />
|
||||
{session.parentSessionId ? '子代理详情' : '主代理详情'}
|
||||
</CardTitle>
|
||||
<CardDescription className="font-mono text-xs text-muted-foreground break-all">
|
||||
ID: {session.id}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 text-sm">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">代理类型</span>
|
||||
<span className="font-semibold text-foreground">{session.agentType}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">运行模型</span>
|
||||
<span className="font-mono font-semibold text-foreground">{session.model}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">启动时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.startedAt)}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-muted-foreground text-xs">结束时间</span>
|
||||
<span className="text-foreground">{formatDateTime(session.completedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.error && (
|
||||
<div className="mt-4 p-3 rounded-lg border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold">执行错误</div>
|
||||
<pre className="mt-1 font-mono text-xs whitespace-pre-wrap break-all">
|
||||
{typeof session.error === 'object' ? JSON.stringify(session.error, null, 2) : String(session.error)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
|
||||
<div className="border-b border-border/50 px-4 py-2 bg-muted/30 shrink-0">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={activeTab === 'messages' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('messages')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<FileText className="w-3.5 h-3.5 mr-1.5" />
|
||||
消息记录 ({session.messages?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'tools' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('tools')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Terminal className="w-3.5 h-3.5 mr-1.5" />
|
||||
工具调用 ({session.toolCalls?.length ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === 'raw' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab('raw')}
|
||||
className="text-xs h-8"
|
||||
>
|
||||
<Info className="w-3.5 h-3.5 mr-1.5" />
|
||||
元数据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||
{activeTab === 'messages' && (
|
||||
<div className="space-y-4">
|
||||
{session.messages && session.messages.length > 0 ? (
|
||||
session.messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex flex-col p-3 rounded-xl border ${
|
||||
msg.role === 'user'
|
||||
? 'border-primary/20 bg-primary/5 ml-8'
|
||||
: msg.role === 'assistant'
|
||||
? 'border-border bg-muted/40 mr-8'
|
||||
: 'border-warning/20 bg-warning/5 mx-4'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={`text-xs font-bold uppercase tracking-wider ${
|
||||
msg.role === 'user' ? 'text-primary' : msg.role === 'assistant' ? 'text-foreground' : 'text-warning'
|
||||
}`}>
|
||||
{msg.role}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{formatDateTime(msg.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap break-all font-sans leading-relaxed">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content
|
||||
: typeof msg.content === 'object' && msg.content !== null && 'text' in msg.content
|
||||
? String(msg.content.text)
|
||||
: JSON.stringify(msg.content, null, 2)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无消息记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tools' && (
|
||||
<div className="space-y-4">
|
||||
{session.toolCalls && session.toolCalls.length > 0 ? (
|
||||
session.toolCalls.map((tool) => (
|
||||
<div key={tool.id} className="border border-border/60 rounded-xl overflow-hidden bg-muted/20">
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5 text-primary" />
|
||||
<span className="font-mono text-sm font-bold text-foreground">{tool.toolName}</span>
|
||||
</div>
|
||||
<StatusBadge status={tool.status} />
|
||||
</div>
|
||||
<div className="p-3 space-y-3 text-xs font-mono">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">参数 (Arguments)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(tool.arguments, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{tool.result !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">结果 (Result)</div>
|
||||
<pre className="p-2 rounded-lg bg-muted/80 border border-border/40 overflow-x-auto whitespace-pre-wrap break-all max-h-60 overflow-y-auto">
|
||||
{typeof tool.result === 'string' ? tool.result : JSON.stringify(tool.result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{tool.error && (
|
||||
<div>
|
||||
<div className="text-danger mb-1">错误 (Error)</div>
|
||||
<pre className="p-2 rounded-lg bg-danger/5 border border-danger/20 text-danger overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof tool.error === 'string' ? tool.error : JSON.stringify(tool.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm">
|
||||
暂无工具调用记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'raw' && (
|
||||
<div className="space-y-4 font-mono text-xs">
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">元数据 (Metadata)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
{session.finalResult !== undefined && (
|
||||
<div>
|
||||
<div className="text-muted-foreground mb-1">最终结果 (Final Result)</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(session.finalResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export default function ReviewSessionsPage() {
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
|
||||
const [selectedSession, setSelectedSession] = useState<AgentSessionTree | null>(null);
|
||||
|
||||
// Fetch runs list
|
||||
const { data: runsData, isLoading: isListLoading, isError: isListError, error: listError } = useQuery({
|
||||
queryKey: ['reviewRuns'],
|
||||
queryFn: () => fetchReviewRuns(50),
|
||||
});
|
||||
|
||||
// Fetch selected run details
|
||||
const { data: runDetails, isLoading: isDetailsLoading, isError: isDetailsError, error: detailsError } = useQuery({
|
||||
queryKey: ['reviewRunDetails', selectedRunId],
|
||||
queryFn: () => fetchReviewRunDetails(selectedRunId!),
|
||||
enabled: !!selectedRunId,
|
||||
});
|
||||
|
||||
const runs = runsData?.data ?? [];
|
||||
|
||||
// Handle run selection
|
||||
const handleSelectRun = (runId: string) => {
|
||||
setSelectedRunId(runId);
|
||||
setSelectedSession(null); // Reset selected session when switching runs
|
||||
};
|
||||
|
||||
// Automatically select first run if none selected
|
||||
if (!selectedRunId && runs.length > 0) {
|
||||
setSelectedRunId(runs[0].id);
|
||||
}
|
||||
|
||||
// Automatically select root session when run details load
|
||||
if (runDetails?.sessionTree && !selectedSession) {
|
||||
setSelectedSession(runDetails.sessionTree);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="theme-page-frame h-[calc(100vh-4rem)] flex flex-col overflow-hidden">
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left Sidebar: Runs List */}
|
||||
<aside className="w-80 border-r border-border/50 flex flex-col bg-muted/10 shrink-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-border/50 shrink-0">
|
||||
<h2 className="text-lg font-bold text-foreground flex items-center gap-2">
|
||||
<Layers className="w-5 h-5 text-primary" />
|
||||
审查任务列表
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">展示最近 50 次自动审查任务</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-2">
|
||||
{isListLoading ? (
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="p-4 rounded-xl border border-border/40 space-y-2">
|
||||
<Skeleton className="h-4 w-3/4 bg-muted/60" />
|
||||
<Skeleton className="h-3 w-1/2 bg-muted/60" />
|
||||
</div>
|
||||
))
|
||||
) : isListError ? (
|
||||
<div className="theme-error-panel flex items-center gap-2 p-4">
|
||||
<AlertCircle className="w-5 h-5 text-danger" />
|
||||
<span className="text-sm font-medium">加载列表失败: {listError.message}</span>
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm">
|
||||
暂无审查任务记录
|
||||
</div>
|
||||
) : (
|
||||
runs.map((run) => {
|
||||
const isSelected = selectedRunId === run.id;
|
||||
return (
|
||||
<div
|
||||
key={run.id}
|
||||
onClick={() => handleSelectRun(run.id)}
|
||||
className={`p-3.5 rounded-xl border transition-all duration-200 cursor-pointer flex flex-col gap-2 ${
|
||||
isSelected
|
||||
? 'border-primary/50 bg-primary/5 theme-glow-primary'
|
||||
: 'border-transparent hover:bg-accent/40 hover:border-border/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<span className="font-bold text-sm text-foreground truncate flex-1">
|
||||
{run.owner}/{run.repo}
|
||||
</span>
|
||||
<StatusBadge status={run.status} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 border-border/60">
|
||||
{run.eventType === 'pull_request' ? `PR #${run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
<span className="truncate font-mono text-[10px]">
|
||||
{run.commitSha?.substring(0, 7) || run.headSha?.substring(0, 7) || '-'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground pt-1 border-t border-border/30">
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
{new Date(run.createdAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
<span>尝试: {run.attempts}/{run.maxAttempts}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right Content: Run Details */}
|
||||
<main className="flex-1 flex flex-col overflow-hidden bg-background">
|
||||
{selectedRunId ? (
|
||||
isDetailsLoading ? (
|
||||
<div className="flex-1 p-6 space-y-6 overflow-y-auto">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-1/3 bg-muted/60" />
|
||||
<Skeleton className="h-4 w-1/4 bg-muted/60" />
|
||||
</div>
|
||||
<Skeleton className="h-[400px] w-full rounded-xl bg-muted/60 border border-border/60" />
|
||||
</div>
|
||||
) : isDetailsError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-6">
|
||||
<div className="theme-error-panel flex items-center gap-3 max-w-md">
|
||||
<AlertCircle className="w-6 h-6 text-danger shrink-0" />
|
||||
<div>
|
||||
<div className="font-bold text-foreground">加载详情失败</div>
|
||||
<div className="text-sm text-muted-foreground mt-1">{detailsError.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !runDetails ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
未找到任务详情
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Detail Header */}
|
||||
<header className="p-6 border-b border-border/50 shrink-0 bg-muted/5">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
||||
{runDetails.run.owner}/{runDetails.run.repo}
|
||||
</h1>
|
||||
<StatusBadge status={runDetails.run.status} />
|
||||
<Badge variant="outline" className="border-border/60">
|
||||
{runDetails.run.eventType === 'pull_request' ? `PR #${runDetails.run.prNumber}` : 'Commit'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
任务 ID: {runDetails.run.id} | Commit: {runDetails.run.commitSha || runDetails.run.headSha || '-'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground shrink-0">
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">创建时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.createdAt)}</span>
|
||||
</div>
|
||||
{runDetails.run.finishedAt && (
|
||||
<div className="flex flex-col items-end">
|
||||
<span className="text-xs text-muted-foreground">完成时间</span>
|
||||
<span className="font-medium text-foreground">{formatDateTime(runDetails.run.finishedAt)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{runDetails.run.error && (
|
||||
<div className="mt-4 p-3 rounded-xl border border-danger/30 bg-danger/5 text-danger text-sm flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-semibold">任务执行失败:</span> {runDetails.run.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Detail Tabs */}
|
||||
<Tabs defaultValue="observability" className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="px-6 border-b border-border/50 bg-muted/5 shrink-0">
|
||||
<TabsList className="h-12 bg-transparent p-0 gap-6 border-b-0">
|
||||
<TabsTrigger
|
||||
value="observability"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
代理观测 (Observability)
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="findings"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
审查结果 ({runDetails.findings?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="log"
|
||||
className="h-12 rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-1 font-semibold text-sm"
|
||||
>
|
||||
运行日志 ({runDetails.steps?.length ?? 0})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* Tab Content: Observability */}
|
||||
<TabsContent value="observability" className="flex-1 overflow-hidden p-6 m-0 flex flex-col md:flex-row gap-6">
|
||||
{runDetails.sessionTree ? (
|
||||
<>
|
||||
{/* Left: Session Tree */}
|
||||
<div className="flex-1 flex flex-col overflow-y-auto pr-2">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider mb-4 flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
代理调用树 (Parent-Child Tree)
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<AgentTreeNode
|
||||
session={runDetails.sessionTree}
|
||||
level={0}
|
||||
onSelectSession={(session) => setSelectedSession(session)}
|
||||
selectedSessionId={selectedSession?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Selected Session Detail */}
|
||||
<div className="flex-1 h-full overflow-hidden">
|
||||
{selectedSession ? (
|
||||
<AgentDetailPanel session={selectedSession} />
|
||||
) : (
|
||||
<div className="h-full border border-dashed border-border/60 rounded-xl flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-12 h-12 text-muted-foreground/40 mb-3 animate-pulse" />
|
||||
<p className="text-sm font-medium">请在左侧选择一个代理节点查看详细调用轨迹</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground border border-dashed border-border/60 rounded-xl p-12">
|
||||
<HelpCircle className="w-12 h-12 text-muted-foreground/40 mb-3" />
|
||||
<p className="text-sm font-medium">本次审查任务未使用 Agent 引擎,或暂无代理调用轨迹数据</p>
|
||||
<p className="text-xs text-muted-foreground/80 mt-1">请确保系统配置中已启用 Agent 审查引擎</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Findings */}
|
||||
<TabsContent value="findings" className="flex-1 overflow-y-auto p-6 m-0 space-y-4">
|
||||
{runDetails.findings && runDetails.findings.length > 0 ? (
|
||||
runDetails.findings.map((finding) => (
|
||||
<Card key={finding.id} className="border-border/60 hover:border-border transition-all duration-200">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<SeverityBadge severity={finding.severity} />
|
||||
<Badge variant="outline" className="bg-muted/50 border-border/60 text-xs">
|
||||
{finding.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{finding.path}:{finding.line}
|
||||
</span>
|
||||
</div>
|
||||
<CardTitle className="text-base font-bold text-foreground tracking-tight">
|
||||
{finding.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground shrink-0 flex items-center gap-1">
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
置信度: {(finding.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4 text-sm">
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">详细描述</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.detail}</p>
|
||||
</div>
|
||||
{finding.evidence && (
|
||||
<div>
|
||||
<div className="font-semibold text-foreground mb-1">代码证据</div>
|
||||
<pre className="p-3 rounded-xl bg-muted/50 border border-border/50 font-mono text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{finding.evidence}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{finding.suggestion && (
|
||||
<div className="p-3.5 rounded-xl border border-success/20 bg-success/5">
|
||||
<div className="font-semibold text-success flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
修改建议
|
||||
</div>
|
||||
<p className="text-muted-foreground leading-relaxed whitespace-pre-wrap">{finding.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
本次审查未发现任何问题
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab Content: Run Log */}
|
||||
<TabsContent value="log" className="flex-1 overflow-y-auto p-6 m-0 space-y-6">
|
||||
{/* Steps */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4" />
|
||||
执行步骤 (Steps)
|
||||
</h3>
|
||||
<div className="border border-border/60 rounded-xl overflow-hidden bg-muted/10">
|
||||
<table className="w-full text-left border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 border-b border-border/50 text-muted-foreground font-semibold">
|
||||
<th className="p-3">步骤名称</th>
|
||||
<th className="p-3">状态</th>
|
||||
<th className="p-3">耗时</th>
|
||||
<th className="p-3">开始时间</th>
|
||||
<th className="p-3">结束时间</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border/40">
|
||||
{runDetails.steps && runDetails.steps.length > 0 ? (
|
||||
runDetails.steps.map((step) => (
|
||||
<tr key={step.id} className="hover:bg-accent/20 transition-colors">
|
||||
<td className="p-3 font-medium text-foreground">{step.stepName}</td>
|
||||
<td className="p-3">
|
||||
<StatusBadge status={step.status} />
|
||||
</td>
|
||||
<td className="p-3 font-mono text-xs">
|
||||
{step.latencyMs ? `${(step.latencyMs / 1000).toFixed(2)}s` : '-'}
|
||||
</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.startedAt)}</td>
|
||||
<td className="p-3 text-xs text-muted-foreground">{formatDateTime(step.finishedAt)}</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={5} className="p-8 text-center text-muted-foreground">
|
||||
暂无步骤记录
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comments */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-muted-foreground uppercase tracking-wider flex items-center gap-2">
|
||||
<FileText className="w-4 h-4" />
|
||||
评论记录 (Comments)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{runDetails.comments && runDetails.comments.length > 0 ? (
|
||||
runDetails.comments.map((comment) => (
|
||||
<div key={comment.id} className="p-4 rounded-xl border border-border/60 bg-muted/20 space-y-2">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
{comment.path && (
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{comment.path}:{comment.line}
|
||||
</span>
|
||||
)}
|
||||
{comment.giteaCommentId && (
|
||||
<Badge variant="outline" className="text-[10px] border-border/60">
|
||||
Gitea ID: {comment.giteaCommentId}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={comment.status} />
|
||||
<span className="text-muted-foreground">{formatDateTime(comment.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap break-all leading-relaxed">
|
||||
{comment.body}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed border-border/60 rounded-xl">
|
||||
暂无评论记录
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground p-6">
|
||||
<Bot className="w-16 h-16 text-muted-foreground/30 mb-4 animate-pulse" />
|
||||
<h3 className="text-lg font-bold text-foreground">请选择一个审查任务</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">在左侧列表中选择一个任务以查看其详细的代理调用轨迹和审查结果</p>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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,14 +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 TestResult {
|
||||
success: boolean;
|
||||
latencyMs?: number;
|
||||
@@ -75,16 +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 testProvider = async (id: string): Promise<TestResult> => {
|
||||
const response = await api.post<TestResult>(`/llm/providers/${id}/test`);
|
||||
return response.data;
|
||||
|
||||
147
frontend/src/services/reviewSessionService.ts
Normal file
147
frontend/src/services/reviewSessionService.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import api from '@/lib/api';
|
||||
|
||||
export type ReviewRunStatus = 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'ignored';
|
||||
|
||||
export interface ReviewRun {
|
||||
id: 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;
|
||||
evidence: string;
|
||||
suggestion: string;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
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 AgentMessageRecord {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
sequence: number;
|
||||
role: string;
|
||||
content: any;
|
||||
metadata: Record<string, any>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
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 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;
|
||||
};
|
||||
@@ -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,6 +51,7 @@
|
||||
"start:prod": "bun run dist/index.js",
|
||||
"lint": "biome check src/",
|
||||
"test": "bun test",
|
||||
"test:e2e": "bash ./e2e/test.sh",
|
||||
"prepare": "command -v husky >/dev/null 2>&1 && husky || true"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
@@ -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';
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
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;
|
||||
}
|
||||
376
src/agent-kernel/session/session-repository.ts
Normal file
376
src/agent-kernel/session/session-repository.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDatabase } from '../../db/database';
|
||||
import { redactSensitiveFields } from './redaction';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentInvocationTranscript,
|
||||
AgentMessageRecord,
|
||||
AgentSessionRecord,
|
||||
AgentSessionStatus,
|
||||
AgentSessionTree,
|
||||
AgentToolCallRecord,
|
||||
AgentToolCallStatus,
|
||||
AppendAgentMessageInput,
|
||||
AppendAgentToolCallInput,
|
||||
CompleteAgentInvocationInput,
|
||||
CompleteAgentSessionInput,
|
||||
CreateAgentInvocationInput,
|
||||
CreateAgentSessionInput,
|
||||
} from './types';
|
||||
|
||||
interface AgentSessionRow {
|
||||
id: 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;
|
||||
}
|
||||
|
||||
interface AgentToolCallRow {
|
||||
id: string;
|
||||
session_id: 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 AgentInvocationRow {
|
||||
id: string;
|
||||
parent_session_id: string;
|
||||
child_session_id: string | null;
|
||||
sequence: number;
|
||||
agent_type: string;
|
||||
model: string;
|
||||
status: AgentSessionStatus;
|
||||
input_json: string;
|
||||
result_json: string | null;
|
||||
error_json: string | null;
|
||||
created_at: string;
|
||||
completed_at: string | null;
|
||||
}
|
||||
|
||||
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,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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 id = input.id ?? randomUUID();
|
||||
db.query(
|
||||
`INSERT INTO agent_sessions (
|
||||
id, parent_session_id, parent_invocation_id, agent_type, model, status, metadata_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
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;
|
||||
}
|
||||
|
||||
getSession(sessionId: string): AgentSessionRecord | null {
|
||||
const db = getDatabase();
|
||||
const row = db
|
||||
.query('SELECT * FROM agent_sessions WHERE id = ?')
|
||||
.get(sessionId) as AgentSessionRow | null;
|
||||
return row ? toSessionRecord(row) : null;
|
||||
}
|
||||
|
||||
appendMessage(input: AppendAgentMessageInput): AgentMessageRecord {
|
||||
const db = getDatabase();
|
||||
const id = input.id ?? randomUUID();
|
||||
const sequence = nextSequence('agent_messages', 'session_id', input.sessionId);
|
||||
db.query(
|
||||
`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.childSessionId ?? null,
|
||||
sequence,
|
||||
input.agentType,
|
||||
input.model,
|
||||
input.status ?? 'running',
|
||||
stringifyJson(input.input ?? {})
|
||||
);
|
||||
return this.getInvocation(id) as AgentInvocationRecord;
|
||||
}
|
||||
|
||||
completeSession(input: CompleteAgentSessionInput): AgentSessionRecord {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`UPDATE agent_sessions
|
||||
SET status = ?, final_result_json = ?, error_json = ?, completed_at = datetime('now'), updated_at = datetime('now')
|
||||
WHERE id = ?`
|
||||
).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;
|
||||
}
|
||||
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord {
|
||||
const db = getDatabase();
|
||||
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;
|
||||
}
|
||||
|
||||
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 FROM agent_sessions
|
||||
WHERE parent_session_id IS NULL
|
||||
AND json_extract(metadata_json, '$.reviewRunId') = ?`
|
||||
)
|
||||
.get(runId) as { id: string } | null;
|
||||
|
||||
if (!row) return null;
|
||||
return this.getSessionTree(row.id);
|
||||
}
|
||||
|
||||
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 {
|
||||
invocation,
|
||||
childSession: invocation.childSessionId
|
||||
? (this.getSessionTree(invocation.childSessionId) ?? undefined)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
408
src/agent-kernel/subagents/__tests__/subagent-runner.test.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
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 type { AgentDefinition } from '../../definitions';
|
||||
import type { MainAgentModelClient, MainAgentTool, MainAgentToolContext } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session';
|
||||
import type { SpawnSubagentExecutionInput } from '../../tools';
|
||||
import { SubagentRunner } from '../subagent-runner';
|
||||
|
||||
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 }),
|
||||
};
|
||||
|
||||
const parentOnlyTool: MainAgentTool = {
|
||||
definition: {
|
||||
name: 'parent_only',
|
||||
description: 'A parent-only tool that must not leak into subagents.',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
execute: () => ({ leaked: true }),
|
||||
};
|
||||
|
||||
function agentDefinition(overrides: Partial<AgentDefinition> = {}): AgentDefinition {
|
||||
return {
|
||||
agentType: 'general-purpose',
|
||||
name: 'General Purpose',
|
||||
whenToUse: 'Use for general delegated work.',
|
||||
source: 'built-in',
|
||||
tools: [],
|
||||
disallowedTools: [],
|
||||
skills: [],
|
||||
hooks: {},
|
||||
maxTurns: 4,
|
||||
permissionMode: 'default',
|
||||
background: false,
|
||||
isolation: 'none',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function parentContext(sessionId: string): MainAgentToolContext {
|
||||
return {
|
||||
sessionId,
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: {
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: '{}',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function executionInput(
|
||||
sessionId: string,
|
||||
overrides: Partial<SpawnSubagentExecutionInput> = {}
|
||||
): SpawnSubagentExecutionInput {
|
||||
const definition = overrides.agentDefinition ?? agentDefinition();
|
||||
return {
|
||||
agentDefinition: definition,
|
||||
agentType: definition.agentType,
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Use lookup, then summarize.',
|
||||
isolation: 'none',
|
||||
parent: parentContext(sessionId),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('SubagentRunner', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `subagent-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 an isolated child loop and links invocation to the child session', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
metadata: { subagentDepth: 0 },
|
||||
});
|
||||
agentSessionRepository.appendMessage({
|
||||
sessionId: parent.id,
|
||||
role: 'user',
|
||||
content: { text: 'parent prompt only' },
|
||||
});
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-lookup-1', name: 'lookup', arguments: '{"query":"alpha"}' }],
|
||||
}),
|
||||
response({ content: 'child concise summary' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
summary: 'child concise summary',
|
||||
messagesCount: 4,
|
||||
toolCallCount: 1,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
});
|
||||
expect(result).not.toHaveProperty('messages');
|
||||
expect(result).not.toHaveProperty('toolCalls');
|
||||
expect(result).not.toHaveProperty('sessionId');
|
||||
expect(result).not.toHaveProperty('totalTokens');
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.messages).toHaveLength(1);
|
||||
expect(tree?.messages[0].content).toEqual({ text: 'parent prompt only' });
|
||||
expect(tree?.toolCalls).toHaveLength(0);
|
||||
expect(tree?.invocations).toHaveLength(1);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
parentSessionId: parent.id,
|
||||
childSessionId: tree?.invocations[0].childSessionId,
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(result.artifacts?.invocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].result).toEqual(result);
|
||||
const invocationTranscript = agentSessionRepository.getInvocationTranscript(
|
||||
tree?.invocations[0].id ?? 'missing'
|
||||
);
|
||||
expect(invocationTranscript?.invocation.result).toEqual(result);
|
||||
expect(invocationTranscript?.childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(invocationTranscript?.childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.parentSessionId).toBe(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.parentInvocationId).toBe(tree?.invocations[0].id);
|
||||
expect(tree?.invocations[0].childSession?.messages.map((message) => message.role)).toEqual([
|
||||
'user',
|
||||
'assistant',
|
||||
'tool',
|
||||
'assistant',
|
||||
]);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
result: { echoed: 'alpha' },
|
||||
});
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('does not leak parent tools into the child model tool definitions', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'no tool needed' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [lookupTool, parentOnlyTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: ['lookup'] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0].tools?.map((tool) => tool.name)).toEqual(['lookup']);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].input).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: ['lookup'],
|
||||
deniedToolNames: ['parent_only'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('persists denied child tool calls as failed unregistered tool calls', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
let lookupExecutions = 0;
|
||||
const countedLookupTool: MainAgentTool = {
|
||||
...lookupTool,
|
||||
execute: () => {
|
||||
lookupExecutions += 1;
|
||||
return { shouldNotRun: true };
|
||||
},
|
||||
};
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [{ id: 'call-denied-lookup', name: 'lookup', arguments: '{"query":"blocked"}' }],
|
||||
}),
|
||||
response({ content: 'saw permission error and stopped' }),
|
||||
]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [countedLookupTool],
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, { agentDefinition: agentDefinition({ tools: [] }) })
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCallCount).toBe(1);
|
||||
expect(lookupExecutions).toBe(0);
|
||||
expect(modelClient.requests[0].tools).toEqual([]);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-denied-lookup',
|
||||
content: JSON.stringify({
|
||||
ok: false,
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].childSession?.toolCalls[0]).toMatchObject({
|
||||
toolName: 'lookup',
|
||||
status: 'failed',
|
||||
arguments: { query: 'blocked' },
|
||||
error: {
|
||||
name: 'ToolNotFoundError',
|
||||
message: "Tool 'lookup' is not registered",
|
||||
},
|
||||
});
|
||||
expect(tree?.invocations[0].childSession?.metadata).toMatchObject({
|
||||
toolPermissions: {
|
||||
allowedToolNames: [],
|
||||
deniedToolNames: ['lookup'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('passes model prompt budgets and optional system prompt to MainAgentRunner', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const modelClient = new FakeModelClient([response({ content: 'system-aware result' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
defaultMaxToolCalls: 3,
|
||||
defaultTimeoutMs: 30_000,
|
||||
});
|
||||
|
||||
const result = await runner.execute(
|
||||
executionInput(parent.id, {
|
||||
agentDefinition: agentDefinition({
|
||||
agentType: 'code-auditor',
|
||||
model: 'definition-model',
|
||||
maxTurns: 2,
|
||||
getSystemPrompt: () => 'subagent system prompt',
|
||||
}),
|
||||
agentType: 'code-auditor',
|
||||
model: 'override-model',
|
||||
prompt: 'Audit deterministically.',
|
||||
})
|
||||
);
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(modelClient.requests[0]).toMatchObject({
|
||||
model: 'override-model',
|
||||
messages: [
|
||||
{ role: 'system', content: 'subagent system prompt' },
|
||||
{ role: 'user', content: 'Audit deterministically.' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('completes invocation with structured failure when child loop throws', async () => {
|
||||
const parent = agentSessionRepository.createSession({ agentType: 'main', model: 'main-model' });
|
||||
const runner = new SubagentRunner({
|
||||
modelClient: new FakeModelClient([]),
|
||||
transcriptRepository: agentSessionRepository,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'failed',
|
||||
summary: 'No fake model response queued',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
error: { code: 'Error', message: 'No fake model response queued' },
|
||||
});
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0].status).toBe('failed');
|
||||
expect(tree?.invocations[0].error).toEqual(result.error);
|
||||
expect(tree?.invocations[0].childSession?.status).toBe('failed');
|
||||
});
|
||||
|
||||
test('blocks execution and returns structured error when recursion depth exceeds limit', async () => {
|
||||
const parent = agentSessionRepository.createSession({
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
metadata: { subagentDepth: 1 },
|
||||
});
|
||||
const modelClient = new FakeModelClient([response({ content: 'must not be used' })]);
|
||||
const runner = new SubagentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
maxDepth: 1,
|
||||
});
|
||||
|
||||
const result = await runner.execute(executionInput(parent.id));
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'failed',
|
||||
summary: 'Subagent recursion depth limit exceeded (1).',
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: expect.any(String) },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: 'Subagent recursion depth 2 exceeds max depth 1.',
|
||||
},
|
||||
});
|
||||
expect(modelClient.requests).toHaveLength(0);
|
||||
const tree = agentSessionRepository.getSessionTree(parent.id);
|
||||
expect(tree?.invocations[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
childSessionId: undefined,
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
expect(tree?.invocations[0].childSession).toBeUndefined();
|
||||
});
|
||||
});
|
||||
6
src/agent-kernel/subagents/index.ts
Normal file
6
src/agent-kernel/subagents/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { SubagentRunner } from './subagent-runner';
|
||||
export type { SubagentResult, SubagentResultStatus } from './subagent-result';
|
||||
export type {
|
||||
SubagentRunnerOptions,
|
||||
SubagentRunnerTranscriptRepository,
|
||||
} from './subagent-runner';
|
||||
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
14
src/agent-kernel/subagents/subagent-result.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export type SubagentResultStatus = 'completed' | 'failed';
|
||||
|
||||
export interface SubagentResult {
|
||||
status: SubagentResultStatus;
|
||||
summary: string;
|
||||
messagesCount: number;
|
||||
toolCallCount: number;
|
||||
totalTokens?: number;
|
||||
artifacts?: Record<string, unknown>;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
243
src/agent-kernel/subagents/subagent-runner.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { MainAgentRunner } from '../loop';
|
||||
import type {
|
||||
MainAgentModelClient,
|
||||
MainAgentTerminalStatus,
|
||||
MainAgentTool,
|
||||
MainAgentTranscriptRepository,
|
||||
} from '../loop';
|
||||
import type {
|
||||
AgentInvocationRecord,
|
||||
AgentMessageRecord,
|
||||
CompleteAgentInvocationInput,
|
||||
CreateAgentInvocationInput,
|
||||
} from '../session';
|
||||
import type { AgentSessionRecord } from '../session';
|
||||
import { resolveAgentTools } from '../tools';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../tools';
|
||||
import type { SubagentResult } from './subagent-result';
|
||||
|
||||
export interface SubagentRunnerTranscriptRepository extends MainAgentTranscriptRepository {
|
||||
createInvocation(input: CreateAgentInvocationInput): AgentInvocationRecord;
|
||||
completeInvocation(input: CompleteAgentInvocationInput): AgentInvocationRecord;
|
||||
getSession?(sessionId: string): AgentSessionRecord | null;
|
||||
listMessages?(sessionId: string): AgentMessageRecord[];
|
||||
}
|
||||
|
||||
export interface SubagentRunnerOptions {
|
||||
modelClient: MainAgentModelClient;
|
||||
transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
tools?: MainAgentTool[];
|
||||
defaultMaxTurns?: number;
|
||||
defaultMaxToolCalls?: number;
|
||||
defaultTimeoutMs?: number;
|
||||
maxDepth?: number;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
function isCompletedStatus(status: MainAgentTerminalStatus): boolean {
|
||||
return status === 'completed';
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): { code: string; message: string } {
|
||||
if (error instanceof Error) {
|
||||
return { code: error.name, message: error.message };
|
||||
}
|
||||
return { code: 'Error', message: String(error) };
|
||||
}
|
||||
|
||||
function readDepth(session: AgentSessionRecord | null | undefined): number {
|
||||
const value = session?.metadata.subagentDepth;
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : 0;
|
||||
}
|
||||
|
||||
function readTotalTokens(messages: AgentMessageRecord[]): number | undefined {
|
||||
let totalTokens = 0;
|
||||
let foundUsage = false;
|
||||
|
||||
for (const message of messages) {
|
||||
const content = message.content;
|
||||
if (typeof content !== 'object' || content === null || !('usage' in content)) continue;
|
||||
|
||||
const usage = (content as { usage?: unknown }).usage;
|
||||
if (typeof usage !== 'object' || usage === null || !('totalTokens' in usage)) continue;
|
||||
|
||||
const value = (usage as { totalTokens?: unknown }).totalTokens;
|
||||
if (typeof value !== 'number') continue;
|
||||
|
||||
totalTokens += value;
|
||||
foundUsage = true;
|
||||
}
|
||||
|
||||
return foundUsage ? totalTokens : undefined;
|
||||
}
|
||||
|
||||
export class SubagentRunner implements SpawnSubagentExecutor {
|
||||
private readonly modelClient: MainAgentModelClient;
|
||||
private readonly transcriptRepository: SubagentRunnerTranscriptRepository;
|
||||
private readonly tools: MainAgentTool[];
|
||||
private readonly defaultMaxTurns: number;
|
||||
private readonly defaultMaxToolCalls: number;
|
||||
private readonly defaultTimeoutMs: number;
|
||||
private readonly maxDepth: number;
|
||||
private readonly now?: () => number;
|
||||
|
||||
constructor(options: SubagentRunnerOptions) {
|
||||
this.modelClient = options.modelClient;
|
||||
this.transcriptRepository = options.transcriptRepository;
|
||||
this.tools = options.tools ?? [];
|
||||
this.defaultMaxTurns = options.defaultMaxTurns ?? 4;
|
||||
this.defaultMaxToolCalls = options.defaultMaxToolCalls ?? 8;
|
||||
this.defaultTimeoutMs = options.defaultTimeoutMs ?? 60_000;
|
||||
this.maxDepth = options.maxDepth ?? 3;
|
||||
this.now = options.now;
|
||||
}
|
||||
|
||||
async execute(input: SpawnSubagentExecutionInput): Promise<SubagentResult> {
|
||||
const toolPermissions = resolveAgentTools({
|
||||
availableTools: this.tools,
|
||||
allowedToolNames: input.agentDefinition.tools,
|
||||
disallowedToolNames: input.agentDefinition.disallowedTools,
|
||||
allowListSpecified: true,
|
||||
});
|
||||
|
||||
const invocation = this.transcriptRepository.createInvocation({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
input: {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation,
|
||||
cwd: input.cwd,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const parentSession = this.transcriptRepository.getSession?.(input.parent.sessionId);
|
||||
const childDepth = readDepth(parentSession) + 1;
|
||||
|
||||
if (childDepth > this.maxDepth) {
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: `Subagent recursion depth limit exceeded (${this.maxDepth}).`,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: {
|
||||
code: 'recursion_depth_exceeded',
|
||||
message: `Subagent recursion depth ${childDepth} exceeds max depth ${this.maxDepth}.`,
|
||||
},
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const childSession = this.transcriptRepository.createSession({
|
||||
parentSessionId: input.parent.sessionId,
|
||||
parentInvocationId: invocation.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
metadata: {
|
||||
subagentDepth: childDepth,
|
||||
description: input.description,
|
||||
parentToolCallId: input.parent.toolCall.id,
|
||||
toolPermissions: {
|
||||
allowedToolNames: toolPermissions.allowedToolNames,
|
||||
disallowedToolNames: toolPermissions.disallowedToolNames,
|
||||
deniedToolNames: toolPermissions.deniedToolNames,
|
||||
unknownAllowedToolNames: toolPermissions.unknownAllowedToolNames,
|
||||
unknownDisallowedToolNames: toolPermissions.unknownDisallowedToolNames,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient: this.modelClient,
|
||||
transcriptRepository: this.transcriptRepository,
|
||||
tools: toolPermissions.tools,
|
||||
now: this.now,
|
||||
});
|
||||
|
||||
try {
|
||||
const runResult = await runner.run({
|
||||
sessionId: childSession.id,
|
||||
agentType: input.agentType,
|
||||
model: input.model,
|
||||
systemPrompt: input.agentDefinition.getSystemPrompt?.(),
|
||||
userMessage: input.prompt,
|
||||
maxTurns: input.agentDefinition.maxTurns ?? this.defaultMaxTurns,
|
||||
maxToolCalls: this.defaultMaxToolCalls,
|
||||
timeoutMs: this.defaultTimeoutMs,
|
||||
});
|
||||
const totalTokens = this.transcriptRepository.listMessages
|
||||
? readTotalTokens(this.transcriptRepository.listMessages(childSession.id))
|
||||
: undefined;
|
||||
|
||||
const result: SubagentResult = {
|
||||
status: isCompletedStatus(runResult.status) ? 'completed' : 'failed',
|
||||
summary: runResult.finalText ?? runResult.status,
|
||||
messagesCount: runResult.messages.length,
|
||||
toolCallCount: runResult.toolCalls,
|
||||
...(totalTokens === undefined ? {} : { totalTokens }),
|
||||
artifacts: { invocationId: invocation.id },
|
||||
...(isCompletedStatus(runResult.status)
|
||||
? {}
|
||||
: {
|
||||
error: {
|
||||
code: runResult.status,
|
||||
message: `Subagent stopped with status ${runResult.status}.`,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: result.status,
|
||||
result,
|
||||
error: result.error,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const normalized = normalizeError(error);
|
||||
const result: SubagentResult = {
|
||||
status: 'failed',
|
||||
summary: normalized.message,
|
||||
messagesCount: 0,
|
||||
toolCallCount: 0,
|
||||
artifacts: { invocationId: invocation.id },
|
||||
error: normalized,
|
||||
};
|
||||
|
||||
this.transcriptRepository.completeSession({
|
||||
sessionId: childSession.id,
|
||||
status: 'failed',
|
||||
error: normalized,
|
||||
});
|
||||
this.transcriptRepository.completeInvocation({
|
||||
invocationId: invocation.id,
|
||||
status: 'failed',
|
||||
result,
|
||||
error: normalized,
|
||||
childSessionId: childSession.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
352
src/agent-kernel/tools/__tests__/spawn-subagent-tool.test.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
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 { createAgentRegistry } from '../../definitions';
|
||||
import { MainAgentRunner } from '../../loop';
|
||||
import type { MainAgentModelClient } from '../../loop';
|
||||
import { agentSessionRepository } from '../../session/session-repository';
|
||||
import { createSpawnSubagentTool } from '../spawn-subagent-tool';
|
||||
import type { SpawnSubagentExecutionInput, SpawnSubagentExecutor } from '../spawn-subagent-tool';
|
||||
|
||||
function agent(agentType: string, name: string, model?: string) {
|
||||
return {
|
||||
agentType,
|
||||
name,
|
||||
whenToUse: `Use ${name}.`,
|
||||
source: 'built-in' as const,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExecutor(result: unknown = { summary: 'subagent done', value: 42 }) {
|
||||
const calls: SpawnSubagentExecutionInput[] = [];
|
||||
const executor: SpawnSubagentExecutor = {
|
||||
execute: (input) => {
|
||||
calls.push(input);
|
||||
return result;
|
||||
},
|
||||
};
|
||||
return { executor, calls };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
describe('createSpawnSubagentTool', () => {
|
||||
test('defaults to general-purpose when subagent_type is omitted', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Summarize', prompt: 'Summarize the change.' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
result: { summary: 'subagent done', value: 42 },
|
||||
summary: 'subagent done',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'general-purpose',
|
||||
model: 'definition-model',
|
||||
description: 'Summarize',
|
||||
prompt: 'Summarize the change.',
|
||||
isolation: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
test('spawns an explicit active subagent type', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor({ summary: 'reviewed' });
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{
|
||||
description: 'Review code',
|
||||
prompt: 'Review this diff.',
|
||||
subagent_type: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
},
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'completed',
|
||||
agentType: 'code-reviewer',
|
||||
model: 'default-subagent-model',
|
||||
description: 'Review code',
|
||||
summary: 'reviewed',
|
||||
});
|
||||
expect(calls[0]).toMatchObject({
|
||||
agentType: 'code-reviewer',
|
||||
isolation: 'workspace',
|
||||
cwd: '/tmp/workspace',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns a structured error for unknown subagent types', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [
|
||||
agent('general-purpose', 'General Purpose'),
|
||||
agent('code-reviewer', 'Code Reviewer'),
|
||||
],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Unknown', prompt: 'Run missing agent.', subagent_type: 'missing-agent' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: "Subagent type 'missing-agent' is not active.",
|
||||
requestedType: 'missing-agent',
|
||||
availableTypes: ['code-reviewer', 'general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('uses model override before definition and fallback models', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'definition-model')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({
|
||||
agentRegistry: registry,
|
||||
executor,
|
||||
defaultSubagentModel: 'default-subagent-model',
|
||||
});
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Override', prompt: 'Use override.', model: 'spawn-model' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ status: 'completed', model: 'spawn-model' });
|
||||
expect(calls[0].model).toBe('spawn-model');
|
||||
});
|
||||
|
||||
test('returns a structured unsupported result for background spawns', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor, calls } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Background', prompt: 'Run later.', run_in_background: true },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: 'general-purpose',
|
||||
availableTypes: ['general-purpose'],
|
||||
});
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('returns a structured validation error for missing required arguments', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose')],
|
||||
});
|
||||
const { executor } = makeExecutor();
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
|
||||
const result = await tool.execute(
|
||||
{ description: 'Missing prompt' },
|
||||
{
|
||||
sessionId: 'parent-session',
|
||||
model: 'main-model',
|
||||
turn: 1,
|
||||
toolCall: { id: 'call-1', name: 'spawn_subagent', arguments: '{}' },
|
||||
}
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('spawn_subagent MainAgentRunner integration', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `spawn-subagent-tool-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('executes through MainAgentRunner and persists the parent tool call', async () => {
|
||||
const registry = createAgentRegistry({
|
||||
builtIn: [agent('general-purpose', 'General Purpose', 'subagent-model')],
|
||||
});
|
||||
const { executor } = makeExecutor({ summary: 'finished by fake executor', value: 'ok' });
|
||||
const tool = createSpawnSubagentTool({ agentRegistry: registry, executor });
|
||||
const modelClient = new FakeModelClient([
|
||||
response({
|
||||
finishReason: 'tool_calls',
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'call-spawn-1',
|
||||
name: 'spawn_subagent',
|
||||
arguments: JSON.stringify({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
response({ content: 'parent final' }),
|
||||
]);
|
||||
const runner = new MainAgentRunner({
|
||||
modelClient,
|
||||
transcriptRepository: agentSessionRepository,
|
||||
tools: [tool],
|
||||
});
|
||||
|
||||
const result = await runner.run({
|
||||
agentType: 'main',
|
||||
model: 'main-model',
|
||||
userMessage: 'delegate investigation',
|
||||
maxTurns: 4,
|
||||
maxToolCalls: 4,
|
||||
timeoutMs: 60_000,
|
||||
});
|
||||
|
||||
expect(result.status).toBe('completed');
|
||||
expect(result.toolCalls).toBe(1);
|
||||
expect(modelClient.requests[0].tools?.map((definition) => definition.name)).toContain(
|
||||
'spawn_subagent'
|
||||
);
|
||||
expect(modelClient.requests[1].messages.at(-1)).toEqual({
|
||||
role: 'tool',
|
||||
toolCallId: 'call-spawn-1',
|
||||
content: JSON.stringify({
|
||||
ok: true,
|
||||
value: {
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const tree = agentSessionRepository.getSessionTree(result.sessionId);
|
||||
expect(tree?.toolCalls).toHaveLength(1);
|
||||
expect(tree?.toolCalls[0].toolName).toBe('spawn_subagent');
|
||||
expect(tree?.toolCalls[0].arguments).toEqual({
|
||||
description: 'Investigate issue',
|
||||
prompt: 'Inspect this issue deterministically.',
|
||||
});
|
||||
expect(tree?.toolCalls[0].result).toEqual({
|
||||
status: 'completed',
|
||||
agentType: 'general-purpose',
|
||||
model: 'subagent-model',
|
||||
description: 'Investigate issue',
|
||||
result: { summary: 'finished by fake executor', value: 'ok' },
|
||||
summary: 'finished by fake executor',
|
||||
});
|
||||
});
|
||||
});
|
||||
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
101
src/agent-kernel/tools/__tests__/tool-permissions.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { MainAgentTool } from '../../loop';
|
||||
import type { ToolPermissionScope } from '../../loop/types';
|
||||
import {
|
||||
DEFAULT_SCOPE_POLICY,
|
||||
evaluateToolPermission,
|
||||
resolveAgentTools,
|
||||
} from '../tool-permissions';
|
||||
|
||||
function tool(name: string, scope?: ToolPermissionScope): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
permissionScope: scope,
|
||||
execute: () => ({ name }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('evaluateToolPermission', () => {
|
||||
test('allows read scope', () => {
|
||||
expect(evaluateToolPermission(tool('read_file', 'read')).behavior).toBe('allow');
|
||||
});
|
||||
|
||||
test('denies write scope', () => {
|
||||
expect(evaluateToolPermission(tool('write_file', 'write')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies command scope', () => {
|
||||
expect(evaluateToolPermission(tool('run_bash', 'command')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('denies network scope', () => {
|
||||
expect(evaluateToolPermission(tool('http_request', 'network')).behavior).toBe('deny');
|
||||
});
|
||||
|
||||
test('defaults to read scope when unspecified', () => {
|
||||
expect(evaluateToolPermission(tool('search_code')).behavior).toBe('allow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveAgentTools', () => {
|
||||
const readTool = tool('read_file', 'read');
|
||||
const writeTool = tool('write_file', 'write');
|
||||
const searchTool = tool('search_code', 'read');
|
||||
|
||||
test('includes allowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [writeTool],
|
||||
allowedToolNames: ['write_file'],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(1);
|
||||
expect(resolved.tools[0].definition.name).toBe('write_file');
|
||||
});
|
||||
|
||||
test('excludes disallowed tool names regardless of scope', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['read_file'],
|
||||
disallowedToolNames: ['read_file'],
|
||||
});
|
||||
expect(resolved.tools).toHaveLength(0);
|
||||
expect(resolved.deniedToolNames).toContain('read_file');
|
||||
});
|
||||
|
||||
test('filters by scope policy when not in allowed/disallowed lists', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool, writeTool, searchTool],
|
||||
allowedToolNames: [],
|
||||
disallowedToolNames: [],
|
||||
});
|
||||
const names = resolved.tools.map((t) => t.definition.name);
|
||||
expect(names).toContain('read_file');
|
||||
expect(names).toContain('search_code');
|
||||
expect(names).not.toContain('write_file');
|
||||
});
|
||||
|
||||
test('reports unknown allowed/disallowed names', () => {
|
||||
const resolved = resolveAgentTools({
|
||||
availableTools: [readTool],
|
||||
allowedToolNames: ['missing_tool'],
|
||||
disallowedToolNames: ['ghost_tool'],
|
||||
});
|
||||
expect(resolved.unknownAllowedToolNames).toContain('missing_tool');
|
||||
expect(resolved.unknownDisallowedToolNames).toContain('ghost_tool');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEFAULT_SCOPE_POLICY', () => {
|
||||
test('only allows read scope', () => {
|
||||
expect(DEFAULT_SCOPE_POLICY.read).toBe('allow');
|
||||
expect(DEFAULT_SCOPE_POLICY.write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.command).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.network).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.git_write).toBe('deny');
|
||||
expect(DEFAULT_SCOPE_POLICY.cross_session).toBe('deny');
|
||||
});
|
||||
});
|
||||
12
src/agent-kernel/tools/index.ts
Normal file
12
src/agent-kernel/tools/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { createSpawnSubagentTool } from './spawn-subagent-tool';
|
||||
export { resolveAgentTools } from './tool-permissions';
|
||||
export type {
|
||||
SpawnSubagentExecutionInput,
|
||||
SpawnSubagentExecutor,
|
||||
SpawnSubagentInput,
|
||||
SpawnSubagentToolOptions,
|
||||
} from './spawn-subagent-tool';
|
||||
export type {
|
||||
ResolvedAgentTools,
|
||||
ResolveAgentToolsInput,
|
||||
} from './tool-permissions';
|
||||
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
195
src/agent-kernel/tools/spawn-subagent-tool.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import type { AgentDefinition, AgentIsolation, AgentRegistry } from '../definitions';
|
||||
import type { MainAgentTool, MainAgentToolContext } from '../loop';
|
||||
import { resolveAgentModel } from '../model';
|
||||
|
||||
export interface SpawnSubagentInput {
|
||||
description: string;
|
||||
prompt: string;
|
||||
subagent_type?: string;
|
||||
model?: string;
|
||||
run_in_background?: boolean;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutionInput {
|
||||
agentDefinition: AgentDefinition;
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
prompt: string;
|
||||
isolation?: AgentIsolation;
|
||||
cwd?: string;
|
||||
parent: MainAgentToolContext;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentExecutor {
|
||||
execute(input: SpawnSubagentExecutionInput): Promise<unknown> | unknown;
|
||||
}
|
||||
|
||||
export interface SpawnSubagentToolOptions {
|
||||
agentRegistry: AgentRegistry;
|
||||
executor: SpawnSubagentExecutor;
|
||||
defaultSubagentModel?: string;
|
||||
}
|
||||
|
||||
type SpawnSubagentToolResult =
|
||||
| {
|
||||
status: 'completed';
|
||||
agentType: string;
|
||||
model: string;
|
||||
description: string;
|
||||
result: unknown;
|
||||
summary?: unknown;
|
||||
}
|
||||
| {
|
||||
status: 'error';
|
||||
code: string;
|
||||
message: string;
|
||||
requestedType?: string;
|
||||
availableTypes?: string[];
|
||||
issues?: string[];
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
function parseInput(
|
||||
argumentsValue: unknown
|
||||
): { ok: true; value: SpawnSubagentInput } | { ok: false; issues: string[] } {
|
||||
if (!isRecord(argumentsValue)) {
|
||||
return { ok: false, issues: ['arguments must be an object'] };
|
||||
}
|
||||
|
||||
const issues: string[] = [];
|
||||
const description = optionalString(argumentsValue.description);
|
||||
const prompt = optionalString(argumentsValue.prompt);
|
||||
|
||||
if (!description) issues.push('description is required');
|
||||
if (!prompt) issues.push('prompt is required');
|
||||
|
||||
if (issues.length > 0) return { ok: false, issues };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
description: description as string,
|
||||
prompt: prompt as string,
|
||||
subagent_type: optionalString(argumentsValue.subagent_type),
|
||||
model: optionalString(argumentsValue.model),
|
||||
run_in_background:
|
||||
typeof argumentsValue.run_in_background === 'boolean'
|
||||
? argumentsValue.run_in_background
|
||||
: undefined,
|
||||
isolation: optionalString(argumentsValue.isolation) as AgentIsolation | undefined,
|
||||
cwd: optionalString(argumentsValue.cwd),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function availableTypes(registry: AgentRegistry): string[] {
|
||||
return registry.activeAgents.map((agent) => agent.agentType).sort();
|
||||
}
|
||||
|
||||
function resolveAgentType(
|
||||
input: SpawnSubagentInput,
|
||||
registry: AgentRegistry
|
||||
): AgentDefinition | undefined {
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
return registry.getActiveAgent(requestedType);
|
||||
}
|
||||
|
||||
function extractSummary(result: unknown): unknown {
|
||||
if (isRecord(result) && 'summary' in result) return result.summary;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function createSpawnSubagentTool(options: SpawnSubagentToolOptions): MainAgentTool {
|
||||
return {
|
||||
definition: {
|
||||
name: 'spawn_subagent',
|
||||
description:
|
||||
'Spawn a registered subagent with an explicit prompt and return its structured result.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
description: { type: 'string' },
|
||||
prompt: { type: 'string' },
|
||||
subagent_type: { type: 'string' },
|
||||
model: { type: 'string' },
|
||||
run_in_background: { type: 'boolean' },
|
||||
isolation: { type: 'string', enum: ['none', 'workspace', 'process'] },
|
||||
cwd: { type: 'string' },
|
||||
},
|
||||
required: ['description', 'prompt'],
|
||||
},
|
||||
},
|
||||
async execute(argumentsValue, context): Promise<SpawnSubagentToolResult> {
|
||||
const parsed = parseInput(argumentsValue);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'invalid_arguments',
|
||||
message: 'spawn_subagent requires non-empty description and prompt arguments.',
|
||||
issues: parsed.issues,
|
||||
};
|
||||
}
|
||||
|
||||
const input = parsed.value;
|
||||
const requestedType = input.subagent_type ?? 'general-purpose';
|
||||
const agentDefinition = resolveAgentType(input, options.agentRegistry);
|
||||
if (!agentDefinition) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'unknown_subagent_type',
|
||||
message: `Subagent type '${requestedType}' is not active.`,
|
||||
requestedType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const model = resolveAgentModel({
|
||||
spawnOverride: input.model,
|
||||
agentDefinition,
|
||||
defaultSubagentModel: options.defaultSubagentModel,
|
||||
mainAgentModel: context.model,
|
||||
});
|
||||
|
||||
if (input.run_in_background) {
|
||||
return {
|
||||
status: 'error',
|
||||
code: 'background_execution_unsupported',
|
||||
message:
|
||||
'spawn_subagent background execution is not supported until the isolated SubagentRunner is implemented.',
|
||||
requestedType: agentDefinition.agentType,
|
||||
availableTypes: availableTypes(options.agentRegistry),
|
||||
};
|
||||
}
|
||||
|
||||
const result = await options.executor.execute({
|
||||
agentDefinition,
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
isolation: input.isolation ?? agentDefinition.isolation,
|
||||
cwd: input.cwd,
|
||||
parent: context,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'completed',
|
||||
agentType: agentDefinition.agentType,
|
||||
model,
|
||||
description: input.description,
|
||||
result,
|
||||
summary: extractSummary(result),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
78
src/agent-kernel/tools/tool-permissions.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { MainAgentTool } from '../loop';
|
||||
import type { ToolPermissionBehavior, ToolPermissionScope } from '../loop/types';
|
||||
|
||||
export interface ResolveAgentToolsInput {
|
||||
availableTools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
allowListSpecified?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedAgentTools {
|
||||
tools: MainAgentTool[];
|
||||
allowedToolNames: string[];
|
||||
disallowedToolNames: string[];
|
||||
deniedToolNames: string[];
|
||||
unknownAllowedToolNames: string[];
|
||||
unknownDisallowedToolNames: string[];
|
||||
}
|
||||
|
||||
export interface ToolPermissionDecision {
|
||||
behavior: ToolPermissionBehavior;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SCOPE_POLICY: Record<ToolPermissionScope, ToolPermissionBehavior> = {
|
||||
read: 'allow',
|
||||
write: 'deny',
|
||||
command: 'deny',
|
||||
network: 'deny',
|
||||
git_write: 'deny',
|
||||
cross_session: 'deny',
|
||||
};
|
||||
|
||||
function uniqueNames(names: string[]): string[] {
|
||||
return [...new Set(names)];
|
||||
}
|
||||
|
||||
export function evaluateToolPermission(tool: MainAgentTool): ToolPermissionDecision {
|
||||
const scope = tool.permissionScope ?? 'read';
|
||||
const behavior = DEFAULT_SCOPE_POLICY[scope];
|
||||
return {
|
||||
behavior,
|
||||
reason: `Tool '${tool.definition.name}' ${behavior === 'allow' ? 'allowed' : 'denied'} for scope '${scope}'`,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentTools(input: ResolveAgentToolsInput): ResolvedAgentTools {
|
||||
const availableToolNames = uniqueNames(input.availableTools.map((tool) => tool.definition.name));
|
||||
const availableToolNamesSet = new Set(availableToolNames);
|
||||
const allowedToolNames = uniqueNames(input.allowedToolNames);
|
||||
const disallowedToolNames = uniqueNames(input.disallowedToolNames);
|
||||
const allowedToolNamesSet = new Set(allowedToolNames);
|
||||
const disallowedToolNamesSet = new Set(disallowedToolNames);
|
||||
|
||||
const tools = input.availableTools.filter((tool) => {
|
||||
const toolName = tool.definition.name;
|
||||
if (disallowedToolNamesSet.has(toolName)) return false;
|
||||
if (allowedToolNamesSet.size > 0) return allowedToolNamesSet.has(toolName);
|
||||
if (input.allowListSpecified) return false;
|
||||
return evaluateToolPermission(tool).behavior === 'allow';
|
||||
});
|
||||
const permittedToolNamesSet = new Set(tools.map((tool) => tool.definition.name));
|
||||
|
||||
return {
|
||||
tools,
|
||||
allowedToolNames,
|
||||
disallowedToolNames,
|
||||
deniedToolNames: availableToolNames.filter((toolName) => !permittedToolNamesSet.has(toolName)),
|
||||
unknownAllowedToolNames: allowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
unknownDisallowedToolNames: disallowedToolNames.filter(
|
||||
(toolName) => !availableToolNamesSet.has(toolName)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export { DEFAULT_SCOPE_POLICY };
|
||||
@@ -86,7 +86,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.notification.feishu.webhookSecret).toBeUndefined();
|
||||
expect(cfg.notification.wecom.webhookUrl).toBeUndefined();
|
||||
expect(cfg.admin.giteaAdminToken).toBeUndefined();
|
||||
expect(cfg.review.qdrantUrl).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns review size thresholds and token budget defaults', () => {
|
||||
@@ -99,6 +98,12 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(cfg.review.tokenBudgetMedium).toBe(45000);
|
||||
expect(cfg.review.tokenBudgetLarge).toBe(120000);
|
||||
});
|
||||
|
||||
test('returns runtime agent model defaults', () => {
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('gpt-4.1');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── 2. setOverrides() / getSource() ─────────────────────────────────────
|
||||
@@ -195,16 +200,6 @@ describe('ConfigManager (DB backend)', () => {
|
||||
// ─── 5. Type conversions ─────────────────────────────────────────────────
|
||||
|
||||
describe('type conversions in getCurrent()', () => {
|
||||
test('boolean field "true" → true', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'true' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(true);
|
||||
});
|
||||
|
||||
test('boolean field "false" → false', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ENABLE_HUMAN_GATE: 'false' });
|
||||
expect(configManager.getCurrent().review.enableHumanGate).toBe(false);
|
||||
});
|
||||
|
||||
test('number field is parsed correctly', async () => {
|
||||
await configManager.setOverrides({ REVIEW_MAX_PARALLEL_RUNS: '4' });
|
||||
expect(configManager.getCurrent().review.maxParallelRuns).toBe(4);
|
||||
@@ -220,6 +215,17 @@ describe('ConfigManager (DB backend)', () => {
|
||||
expect(configManager.getCurrent().review.tokenBudgetSmall).toBe(22222);
|
||||
});
|
||||
|
||||
test('agent model fields are read from overrides', async () => {
|
||||
await configManager.setOverrides({
|
||||
AGENT_MAIN_MODEL: 'main-override-model',
|
||||
AGENT_DEFAULT_SUBAGENT_MODEL: 'subagent-override-model',
|
||||
});
|
||||
|
||||
const cfg = configManager.getCurrent();
|
||||
expect(cfg.review.agentMainModel).toBe('main-override-model');
|
||||
expect(cfg.review.agentDefaultSubagentModel).toBe('subagent-override-model');
|
||||
});
|
||||
|
||||
test('comma-separated REVIEW_ALLOWED_COMMANDS parsed to array', async () => {
|
||||
await configManager.setOverrides({ REVIEW_ALLOWED_COMMANDS: 'git, rg, cat' });
|
||||
expect(configManager.getCurrent().review.allowedCommands).toEqual(['git', 'rg', 'cat']);
|
||||
|
||||
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
31
src/config/__tests__/config-schema-agent-model.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { CONFIG_FIELDS } from '../config-schema';
|
||||
|
||||
function findField(envKey: string) {
|
||||
const field = CONFIG_FIELDS.find((item) => item.envKey === envKey);
|
||||
expect(field).toBeDefined();
|
||||
return field!;
|
||||
}
|
||||
|
||||
describe('config-schema agent model fields', () => {
|
||||
test('AGENT_MAIN_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_MAIN_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_MAIN_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1');
|
||||
});
|
||||
|
||||
test('AGENT_DEFAULT_SUBAGENT_MODEL exists with expected metadata and default', () => {
|
||||
const field = findField('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
|
||||
expect(field.envKey).toBe('AGENT_DEFAULT_SUBAGENT_MODEL');
|
||||
expect(field.group).toBe('review');
|
||||
expect(field.type).toBe('string');
|
||||
expect(field.sensitive).toBe(false);
|
||||
expect(field.defaultValue).toBe('gpt-4.1-mini');
|
||||
});
|
||||
});
|
||||
@@ -38,14 +38,13 @@ export interface AppConfig {
|
||||
maxParallelRuns: number;
|
||||
maxFilesPerRun: number;
|
||||
maxFileContentChars: number;
|
||||
autoPublishMinConfidence: number;
|
||||
enableHumanGate: boolean;
|
||||
allowedCommands: string[];
|
||||
commandTimeoutMs: number;
|
||||
llmMaxConcurrentCalls: number;
|
||||
llmRetryMaxAttempts: number;
|
||||
llmRetryBaseDelayMs: number;
|
||||
enableTriage: boolean;
|
||||
agentMainModel: string;
|
||||
agentDefaultSubagentModel: string;
|
||||
smallMaxFiles: number;
|
||||
smallMaxChangedLines: number;
|
||||
mediumMaxFiles: number;
|
||||
@@ -58,13 +57,6 @@ export interface AppConfig {
|
||||
codexModel: string;
|
||||
codexTimeoutMs: number;
|
||||
codexReviewPrompt: string | undefined;
|
||||
qdrantUrl: string | undefined;
|
||||
enableMemory: boolean;
|
||||
fewShotExamplesCount: number;
|
||||
enableReflection: boolean;
|
||||
maxReflectionRounds: number;
|
||||
enableDebate: boolean;
|
||||
debateThreshold: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,8 +161,6 @@ class ConfigManager {
|
||||
maxParallelRuns: toNumber('REVIEW_MAX_PARALLEL_RUNS', 2),
|
||||
maxFilesPerRun: toNumber('REVIEW_MAX_FILES_PER_RUN', 200),
|
||||
maxFileContentChars: toNumber('REVIEW_MAX_FILE_CONTENT_CHARS', 40000),
|
||||
autoPublishMinConfidence: toNumber('REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE', 0.8),
|
||||
enableHumanGate: toBoolean('REVIEW_ENABLE_HUMAN_GATE', true),
|
||||
allowedCommands: toStringArray('REVIEW_ALLOWED_COMMANDS', [
|
||||
'git',
|
||||
'rg',
|
||||
@@ -178,11 +168,12 @@ class ConfigManager {
|
||||
'sed',
|
||||
'wc',
|
||||
]),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 10000),
|
||||
commandTimeoutMs: toNumber('REVIEW_COMMAND_TIMEOUT_MS', 120000),
|
||||
llmMaxConcurrentCalls: toNumber('LLM_MAX_CONCURRENT_CALLS', 4),
|
||||
llmRetryMaxAttempts: toNumber('LLM_RETRY_MAX_ATTEMPTS', 3),
|
||||
llmRetryBaseDelayMs: toNumber('LLM_RETRY_BASE_DELAY_MS', 1000),
|
||||
enableTriage: toBoolean('ENABLE_TRIAGE', true),
|
||||
agentMainModel: values.AGENT_MAIN_MODEL ?? 'gpt-4.1',
|
||||
agentDefaultSubagentModel: values.AGENT_DEFAULT_SUBAGENT_MODEL ?? 'gpt-4.1-mini',
|
||||
smallMaxFiles: toNumber('REVIEW_SMALL_MAX_FILES', 3),
|
||||
smallMaxChangedLines: toNumber('REVIEW_SMALL_MAX_CHANGED_LINES', 80),
|
||||
mediumMaxFiles: toNumber('REVIEW_MEDIUM_MAX_FILES', 10),
|
||||
@@ -195,13 +186,6 @@ class ConfigManager {
|
||||
codexModel: values.CODEX_MODEL ?? 'o3',
|
||||
codexTimeoutMs: toNumber('CODEX_TIMEOUT_MS', 300000),
|
||||
codexReviewPrompt: values.CODEX_REVIEW_PROMPT,
|
||||
qdrantUrl: values.QDRANT_URL,
|
||||
enableMemory: toBoolean('ENABLE_MEMORY', false),
|
||||
fewShotExamplesCount: toNumber('FEW_SHOT_EXAMPLES_COUNT', 10),
|
||||
enableReflection: toBoolean('ENABLE_REFLECTION', false),
|
||||
maxReflectionRounds: toNumber('MAX_REFLECTION_ROUNDS', 2),
|
||||
enableDebate: toBoolean('ENABLE_DEBATE', false),
|
||||
debateThreshold: values.DEBATE_THRESHOLD ?? 'high',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review' | 'memory';
|
||||
export type ConfigGroup = 'gitea' | 'notification' | 'security' | 'review';
|
||||
|
||||
export type ConfigFieldType = 'string' | 'number' | 'boolean' | 'url' | 'text' | 'enum';
|
||||
|
||||
@@ -60,12 +60,6 @@ export const CONFIG_GROUPS: ConfigGroupMeta[] = [
|
||||
description: 'Agent 审查模式、并发与沙箱设置',
|
||||
icon: 'file-check',
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: '记忆与学习',
|
||||
description: '向量记忆、反思与辩论系统',
|
||||
icon: 'brain',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -188,7 +182,7 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
envKey: 'REVIEW_ENGINE',
|
||||
group: 'review',
|
||||
label: '审查引擎',
|
||||
description: '代码审查模式:agent(任务化分级编排)或 codex(Codex CLI)',
|
||||
description: '代码审查模式:agent(内置 Agent 审查)或 codex(Codex CLI)',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['agent', 'codex'],
|
||||
@@ -236,26 +230,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
max: 1000000,
|
||||
defaultValue: 40000,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_AUTO_PUBLISH_MIN_CONFIDENCE',
|
||||
group: 'review',
|
||||
label: '自动发布置信度',
|
||||
description: '自动发布评论所需的最小置信度(0~1)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 1,
|
||||
defaultValue: 0.8,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ENABLE_HUMAN_GATE',
|
||||
group: 'review',
|
||||
label: '人工审批',
|
||||
description: '是否启用人工审批队列(低置信度评论需人工确认后发布)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_ALLOWED_COMMANDS',
|
||||
group: 'review',
|
||||
@@ -272,9 +246,9 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
description: '单条本地命令的执行超时时间(毫秒)',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1000,
|
||||
min: 120000,
|
||||
max: 300000,
|
||||
defaultValue: 10000,
|
||||
defaultValue: 120000,
|
||||
},
|
||||
{
|
||||
envKey: 'LLM_MAX_CONCURRENT_CALLS',
|
||||
@@ -310,13 +284,22 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
defaultValue: 1000,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_TRIAGE',
|
||||
envKey: 'AGENT_MAIN_MODEL',
|
||||
group: 'review',
|
||||
label: '启用变更分流',
|
||||
description: '是否启用 Triage 分流(用 Planner 模型先评估变更复杂度,再按需派发 Specialist)',
|
||||
type: 'boolean',
|
||||
label: 'Agent 主模型',
|
||||
description: 'Agent runtime 在没有更具体模型配置时使用的主模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: true,
|
||||
defaultValue: 'gpt-4.1',
|
||||
},
|
||||
{
|
||||
envKey: 'AGENT_DEFAULT_SUBAGENT_MODEL',
|
||||
group: 'review',
|
||||
label: 'Subagent 默认模型',
|
||||
description: 'Subagent 未声明模型且 spawn 未覆盖时使用的默认模型名称',
|
||||
type: 'string',
|
||||
sensitive: false,
|
||||
defaultValue: 'gpt-4.1-mini',
|
||||
},
|
||||
{
|
||||
envKey: 'REVIEW_SMALL_MAX_FILES',
|
||||
@@ -442,75 +425,6 @@ export const CONFIG_FIELDS: ConfigFieldMeta[] = [
|
||||
type: 'text',
|
||||
sensitive: false,
|
||||
},
|
||||
|
||||
// ── 记忆与学习 ──────────────────────────────────────────────────────────
|
||||
{
|
||||
envKey: 'QDRANT_URL',
|
||||
group: 'memory',
|
||||
label: 'Qdrant 地址',
|
||||
description: 'Qdrant 向量数据库的连接 URL',
|
||||
type: 'url',
|
||||
sensitive: false,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_MEMORY',
|
||||
group: 'memory',
|
||||
label: '启用记忆',
|
||||
description: '是否启用向量记忆系统(需配置 Qdrant)',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'FEW_SHOT_EXAMPLES_COUNT',
|
||||
group: 'memory',
|
||||
label: 'Few-shot 示例数',
|
||||
description: '检索的 few-shot 示例数量',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 0,
|
||||
max: 20,
|
||||
defaultValue: 10,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_REFLECTION',
|
||||
group: 'memory',
|
||||
label: '启用反思',
|
||||
description: '是否启用审查结果自我反思机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'MAX_REFLECTION_ROUNDS',
|
||||
group: 'memory',
|
||||
label: '最大反思轮数',
|
||||
description: '反思迭代的最大轮数',
|
||||
type: 'number',
|
||||
sensitive: false,
|
||||
min: 1,
|
||||
max: 5,
|
||||
defaultValue: 2,
|
||||
},
|
||||
{
|
||||
envKey: 'ENABLE_DEBATE',
|
||||
group: 'memory',
|
||||
label: '启用辩论',
|
||||
description: '是否启用多视角辩论机制',
|
||||
type: 'boolean',
|
||||
sensitive: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
{
|
||||
envKey: 'DEBATE_THRESHOLD',
|
||||
group: 'memory',
|
||||
label: '辩论阈值',
|
||||
description: '触发辩论的严重程度阈值',
|
||||
type: 'enum',
|
||||
sensitive: false,
|
||||
enumValues: ['high', 'medium'],
|
||||
defaultValue: 'high',
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
77
src/controllers/__tests__/admin-review-runs.test.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
import { agentSessionRepository } from '../../agent-kernel/session';
|
||||
import { reviewEngine } from '../../review/engine';
|
||||
import { adminController } from '../admin';
|
||||
|
||||
function createTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.route('/admin/api', adminController.protectedRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('admin review runs route', () => {
|
||||
const originalGetRunDetails = reviewEngine.getRunDetails;
|
||||
const originalGetSessionTreeByRunId = agentSessionRepository.getSessionTreeByRunId;
|
||||
|
||||
afterEach(() => {
|
||||
reviewEngine.getRunDetails = originalGetRunDetails;
|
||||
agentSessionRepository.getSessionTreeByRunId = originalGetSessionTreeByRunId;
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns run details with sessionTree', async () => {
|
||||
const mockRunDetails = {
|
||||
run: {
|
||||
id: 'run-123',
|
||||
status: 'succeeded',
|
||||
owner: 'test-owner',
|
||||
repo: 'test-repo',
|
||||
createdAt: '2026-05-25T00:00:00.000Z',
|
||||
},
|
||||
steps: [],
|
||||
findings: [],
|
||||
comments: [],
|
||||
};
|
||||
|
||||
const mockSessionTree = {
|
||||
id: 'session-123',
|
||||
agentType: 'review-main-agent',
|
||||
model: 'gpt-main',
|
||||
status: 'completed',
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
invocations: [],
|
||||
};
|
||||
|
||||
reviewEngine.getRunDetails = async (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockRunDetails as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
agentSessionRepository.getSessionTreeByRunId = (runId) => {
|
||||
if (runId === 'run-123') {
|
||||
return mockSessionTree as any;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/run-123');
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const payload = await response.json();
|
||||
expect(payload.run.id).toBe('run-123');
|
||||
expect(payload.sessionTree.id).toBe('session-123');
|
||||
expect(payload.sessionTree.agentType).toBe('review-main-agent');
|
||||
});
|
||||
|
||||
test('GET /admin/api/review/runs/:runId returns 404 if run not found', async () => {
|
||||
reviewEngine.getRunDetails = async () => null;
|
||||
|
||||
const app = createTestApp();
|
||||
const response = await app.request('http://localhost/admin/api/review/runs/missing-run');
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
201
src/controllers/__tests__/agents.test.ts
Normal file
201
src/controllers/__tests__/agents.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { jwt, sign } from 'hono/jwt';
|
||||
import config from '../../config';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { agentsRouter } from '../agents';
|
||||
|
||||
function createProtectedTestApp(): Hono {
|
||||
const app = new Hono();
|
||||
app.use('/admin/api/*', (c, next) => {
|
||||
const middleware = jwt({ secret: config.admin.jwtSecret, alg: 'HS256' });
|
||||
return middleware(c, next);
|
||||
});
|
||||
app.route('/admin/api/agents', agentsRouter);
|
||||
return app;
|
||||
}
|
||||
|
||||
async function createAdminToken(): Promise<string> {
|
||||
return sign(
|
||||
{
|
||||
sub: 'admin',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
},
|
||||
config.admin.jwtSecret
|
||||
);
|
||||
}
|
||||
|
||||
async function jsonRequest(
|
||||
app: Hono,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
token?: string
|
||||
): Promise<{ status: number; data: any }> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const init: RequestInit = { method, headers };
|
||||
if (body !== undefined) {
|
||||
init.body = JSON.stringify(body);
|
||||
}
|
||||
const res = await app.request(`http://localhost/admin/api/agents${path}`, init);
|
||||
const text = await res.text();
|
||||
try {
|
||||
return { status: res.status, data: JSON.parse(text) };
|
||||
} catch {
|
||||
return { status: res.status, data: { _raw: text } };
|
||||
}
|
||||
}
|
||||
|
||||
describe('agents controller', () => {
|
||||
let dbPath: string;
|
||||
let app: Hono;
|
||||
let tempProjectRoot: string;
|
||||
let savedCwd: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
const savedEncryptionKey = process.env.ENCRYPTION_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDbDir = join(tmpdir(), `agents-ctrl-db-${randomUUID()}`);
|
||||
mkdirSync(tmpDbDir, { recursive: true });
|
||||
dbPath = join(tmpDbDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
process.env.ENCRYPTION_KEY = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString(
|
||||
'hex'
|
||||
);
|
||||
|
||||
tempProjectRoot = join(tmpdir(), `agents-project-${randomUUID()}`);
|
||||
mkdirSync(join(tempProjectRoot, '.gitea-assistant', 'agents'), { recursive: true });
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'alpha.md'),
|
||||
[
|
||||
'---',
|
||||
'agentType: alpha-reviewer',
|
||||
'name: Alpha Reviewer',
|
||||
'whenToUse: Use alpha reviewer for repository checks.',
|
||||
'tools: [read_file, search_code]',
|
||||
'model: gpt-4.1',
|
||||
'maxTurns: 3',
|
||||
'---',
|
||||
'You are alpha reviewer.',
|
||||
].join('\n')
|
||||
);
|
||||
writeFileSync(
|
||||
join(tempProjectRoot, '.gitea-assistant', 'agents', 'broken.md'),
|
||||
['---', 'agentType: broken', 'name: Broken Agent', '---', ' '].join('\n')
|
||||
);
|
||||
|
||||
savedCwd = process.cwd();
|
||||
process.chdir(tempProjectRoot);
|
||||
|
||||
initMasterKey();
|
||||
initDatabase();
|
||||
app = createProtectedTestApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.chdir(savedCwd);
|
||||
closeDatabase();
|
||||
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
|
||||
if (savedEncryptionKey === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'ENCRYPTION_KEY');
|
||||
} else {
|
||||
process.env.ENCRYPTION_KEY = savedEncryptionKey;
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
} catch {}
|
||||
try {
|
||||
if (existsSync(tempProjectRoot)) rmSync(tempProjectRoot, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('GET /definitions returns active/all definitions and load errors', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/definitions', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(Array.isArray(data.activeDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.allDefinitions)).toBe(true);
|
||||
expect(Array.isArray(data.loadErrors)).toBe(true);
|
||||
|
||||
const alpha = data.activeDefinitions.find((item: any) => item.agentType === 'alpha-reviewer');
|
||||
expect(alpha).toBeDefined();
|
||||
expect(alpha.source).toBe('project');
|
||||
expect(alpha.tools).toEqual(['read_file', 'search_code']);
|
||||
expect(alpha.model).toBe('gpt-4.1');
|
||||
expect(alpha.maxTurns).toBe(3);
|
||||
|
||||
const broken = data.loadErrors.find((item: any) => item.filePath.endsWith('broken.md'));
|
||||
expect(broken).toBeDefined();
|
||||
expect(broken.code).toBe('empty_body');
|
||||
});
|
||||
|
||||
test('GET /model-config returns runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveProperty('agentMainModel');
|
||||
expect(data).toHaveProperty('agentDefaultSubagentModel');
|
||||
expect(data).toHaveProperty('source');
|
||||
});
|
||||
|
||||
test('PUT /model-config updates runtime model defaults', async () => {
|
||||
const token = await createAdminToken();
|
||||
const updateRes = await jsonRequest(
|
||||
app,
|
||||
'PUT',
|
||||
'/model-config',
|
||||
{
|
||||
agentMainModel: 'gpt-4.1-updated',
|
||||
agentDefaultSubagentModel: 'gpt-4.1-mini-updated',
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
expect(updateRes.status).toBe(200);
|
||||
expect(updateRes.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(updateRes.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
expect(updateRes.data.source.agentMainModel).toBe('db');
|
||||
expect(updateRes.data.source.agentDefaultSubagentModel).toBe('db');
|
||||
|
||||
const readBack = await jsonRequest(app, 'GET', '/model-config', undefined, token);
|
||||
expect(readBack.status).toBe(200);
|
||||
expect(readBack.data.agentMainModel).toBe('gpt-4.1-updated');
|
||||
expect(readBack.data.agentDefaultSubagentModel).toBe('gpt-4.1-mini-updated');
|
||||
});
|
||||
|
||||
test('returns 401 when missing authorization token', async () => {
|
||||
const defsRes = await jsonRequest(app, 'GET', '/definitions');
|
||||
expect(defsRes.status).toBe(401);
|
||||
|
||||
const getModelRes = await jsonRequest(app, 'GET', '/model-config');
|
||||
expect(getModelRes.status).toBe(401);
|
||||
|
||||
const putModelRes = await jsonRequest(app, 'PUT', '/model-config', {
|
||||
agentMainModel: 'nope',
|
||||
});
|
||||
expect(putModelRes.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { join } from 'node:path';
|
||||
import { Hono } from 'hono';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../../db/repositories/secret-repo';
|
||||
import { llmConfigRouter } from '../llm-config';
|
||||
@@ -150,19 +149,6 @@ describe('llm-config controller', () => {
|
||||
expect(data.hasKey).toBe(true);
|
||||
});
|
||||
|
||||
test('auto-binds all roles when first provider is created', async () => {
|
||||
await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'First Provider',
|
||||
type: 'gemini',
|
||||
defaultModel: 'gemini-pro',
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
const { data: roles } = await jsonRequest(app, 'GET', '/roles');
|
||||
const assignedRoles = roles.filter((r: any) => r.providerId !== null);
|
||||
expect(assignedRoles).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('rejects missing required fields', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'POST', '/providers', {
|
||||
name: 'Missing Type',
|
||||
@@ -239,7 +225,7 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
|
||||
describe('DELETE /providers/:id', () => {
|
||||
test('deletes provider without role assignments', async () => {
|
||||
test('deletes provider', async () => {
|
||||
const created = providerRepo.create({
|
||||
name: 'ToDelete',
|
||||
type: 'anthropic',
|
||||
@@ -249,7 +235,6 @@ describe('llm-config controller', () => {
|
||||
const { status, data } = await jsonRequest(app, 'DELETE', `/providers/${created.id}`);
|
||||
expect(status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.removedRoleAssignments).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
@@ -320,75 +305,19 @@ describe('llm-config controller', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Role Assignments ────────────────────────────────────────────
|
||||
|
||||
describe('GET /roles', () => {
|
||||
test('returns all MODEL_ROLES with null assignments when unassigned', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'GET', '/roles');
|
||||
expect(status).toBe(200);
|
||||
expect(data).toHaveLength(4);
|
||||
expect(data[0]).toHaveProperty('role');
|
||||
expect(data[0]).toHaveProperty('providerId');
|
||||
describe('removed legacy model binding API', () => {
|
||||
test('returns 404 for old role list endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles'].join('');
|
||||
const { status } = await jsonRequest(app, 'GET', legacyRolePath);
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
|
||||
test('returns assigned role info when set', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'RoleTest',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
});
|
||||
modelRoleRepo.set('planner', provider.id, 'gpt-4o');
|
||||
|
||||
const { data } = await jsonRequest(app, 'GET', '/roles');
|
||||
const planner = data.find((r: any) => r.role === 'planner');
|
||||
expect(planner.providerId).toBe(provider.id);
|
||||
expect(planner.providerName).toBe('RoleTest');
|
||||
expect(planner.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /roles/:role', () => {
|
||||
test('assigns a role to a provider+model', async () => {
|
||||
const provider = providerRepo.create({
|
||||
name: 'AssignTarget',
|
||||
type: 'anthropic',
|
||||
defaultModel: 'claude-3',
|
||||
});
|
||||
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: provider.id,
|
||||
model: 'claude-3-5-sonnet',
|
||||
});
|
||||
|
||||
expect(status).toBe(200);
|
||||
expect(data.role).toBe('planner');
|
||||
expect(data.providerId).toBe(provider.id);
|
||||
expect(data.model).toBe('claude-3-5-sonnet');
|
||||
});
|
||||
|
||||
test('rejects invalid role name', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/invalid_role', {
|
||||
test('returns 404 for old role update endpoint', async () => {
|
||||
const legacyRolePath = ['/', 'roles', '/', 'old-role'].join('');
|
||||
const { status } = await jsonRequest(app, 'PUT', legacyRolePath, {
|
||||
providerId: 'some-id',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('Invalid role');
|
||||
});
|
||||
|
||||
test('rejects missing providerId or model', async () => {
|
||||
const { status, data } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'some-id',
|
||||
});
|
||||
expect(status).toBe(400);
|
||||
expect(data.message).toContain('providerId and model are required');
|
||||
});
|
||||
|
||||
test('returns 404 for non-existent provider', async () => {
|
||||
const { status } = await jsonRequest(app, 'PUT', '/roles/planner', {
|
||||
providerId: 'non-existent',
|
||||
model: 'model',
|
||||
});
|
||||
expect(status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from 'hono';
|
||||
import { sign } from 'hono/jwt';
|
||||
import { agentSessionRepository } from '../agent-kernel/session';
|
||||
import config from '../config';
|
||||
import { repositoryReviewPromptRepo } from '../db/repositories/repository-review-prompt-repo';
|
||||
import { reviewEngine } from '../review/engine';
|
||||
@@ -189,7 +190,11 @@ protectedRoutes.get('/review/runs/:runId', async (c) => {
|
||||
if (!result) {
|
||||
return c.json({ message: 'Run not found' }, 404);
|
||||
}
|
||||
return c.json(result);
|
||||
const sessionTree = agentSessionRepository.getSessionTreeByRunId(runId);
|
||||
return c.json({
|
||||
...result,
|
||||
sessionTree,
|
||||
});
|
||||
} catch (error: any) {
|
||||
logger.error('获取审查任务详情失败:', error);
|
||||
return c.json({ message: 'Failed to fetch review run details', error: error.message }, 500);
|
||||
|
||||
129
src/controllers/agents.ts
Normal file
129
src/controllers/agents.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { Hono } from 'hono';
|
||||
import {
|
||||
type AgentDefinition,
|
||||
type AgentDefinitionLoadError,
|
||||
loadAgentRegistry,
|
||||
} from '../agent-kernel/definitions';
|
||||
import { configManager } from '../config/config-manager';
|
||||
|
||||
export const agentsRouter = new Hono();
|
||||
|
||||
interface SerializableAgentDefinition {
|
||||
agentType: string;
|
||||
name: string;
|
||||
whenToUse: string;
|
||||
source: AgentDefinition['source'];
|
||||
tools: string[];
|
||||
disallowedTools: string[];
|
||||
skills: string[];
|
||||
model?: string;
|
||||
maxTurns: number;
|
||||
permissionMode: AgentDefinition['permissionMode'];
|
||||
background: boolean;
|
||||
isolation: AgentDefinition['isolation'];
|
||||
}
|
||||
|
||||
interface SerializableLoadError {
|
||||
source: AgentDefinitionLoadError['source'];
|
||||
filePath: string;
|
||||
code: AgentDefinitionLoadError['code'];
|
||||
message: string;
|
||||
issues?: string[];
|
||||
}
|
||||
|
||||
function toSerializableDefinition(definition: AgentDefinition): SerializableAgentDefinition {
|
||||
return {
|
||||
agentType: definition.agentType,
|
||||
name: definition.name,
|
||||
whenToUse: definition.whenToUse,
|
||||
source: definition.source,
|
||||
tools: definition.tools,
|
||||
disallowedTools: definition.disallowedTools,
|
||||
skills: definition.skills,
|
||||
model: definition.model,
|
||||
maxTurns: definition.maxTurns,
|
||||
permissionMode: definition.permissionMode,
|
||||
background: definition.background,
|
||||
isolation: definition.isolation,
|
||||
};
|
||||
}
|
||||
|
||||
function toSerializableLoadError(error: AgentDefinitionLoadError): SerializableLoadError {
|
||||
return {
|
||||
source: error.source,
|
||||
filePath: error.filePath,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
issues: error.issues,
|
||||
};
|
||||
}
|
||||
|
||||
agentsRouter.get('/definitions', async (c) => {
|
||||
const registry = await loadAgentRegistry({ projectRoot: process.cwd() });
|
||||
|
||||
return c.json({
|
||||
activeDefinitions: registry.activeAgents.map(toSerializableDefinition),
|
||||
allDefinitions: registry.allAgents.map(toSerializableDefinition),
|
||||
loadErrors: registry.failedFiles.map(toSerializableLoadError),
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.get('/model-config', (c) => {
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
agentsRouter.put('/model-config', async (c) => {
|
||||
const body = await c.req.json<{
|
||||
agentMainModel?: unknown;
|
||||
agentDefaultSubagentModel?: unknown;
|
||||
}>();
|
||||
|
||||
const updates: Record<string, string> = {};
|
||||
|
||||
if (body.agentMainModel !== undefined) {
|
||||
if (typeof body.agentMainModel !== 'string' || !body.agentMainModel.trim()) {
|
||||
return c.json({ message: 'agentMainModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_MAIN_MODEL = body.agentMainModel.trim();
|
||||
}
|
||||
|
||||
if (body.agentDefaultSubagentModel !== undefined) {
|
||||
if (
|
||||
typeof body.agentDefaultSubagentModel !== 'string' ||
|
||||
!body.agentDefaultSubagentModel.trim()
|
||||
) {
|
||||
return c.json({ message: 'agentDefaultSubagentModel must be a non-empty string' }, 400);
|
||||
}
|
||||
updates.AGENT_DEFAULT_SUBAGENT_MODEL = body.agentDefaultSubagentModel.trim();
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return c.json(
|
||||
{
|
||||
message: 'At least one of agentMainModel or agentDefaultSubagentModel is required',
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
await configManager.setOverrides(updates);
|
||||
const current = configManager.getCurrent();
|
||||
|
||||
return c.json({
|
||||
agentMainModel: current.review.agentMainModel,
|
||||
agentDefaultSubagentModel: current.review.agentDefaultSubagentModel,
|
||||
source: {
|
||||
agentMainModel: configManager.getSource('AGENT_MAIN_MODEL'),
|
||||
agentDefaultSubagentModel: configManager.getSource('AGENT_DEFAULT_SUBAGENT_MODEL'),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -15,8 +15,6 @@ const INTEGER_FIELDS = new Set([
|
||||
'REVIEW_MAX_FILES_PER_RUN',
|
||||
'REVIEW_MAX_FILE_CONTENT_CHARS',
|
||||
'REVIEW_COMMAND_TIMEOUT_MS',
|
||||
'FEW_SHOT_EXAMPLES_COUNT',
|
||||
'MAX_REFLECTION_ROUNDS',
|
||||
]);
|
||||
|
||||
/** Fast lookup from envKey → field metadata. */
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
import { zValidator } from '@hono/zod-validator';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import config from '../config';
|
||||
import { LearningSystem } from '../review/learning/learning-system';
|
||||
import { VectorMemoryStore } from '../review/memory/vector-store';
|
||||
import { FileReviewStore } from '../review/store/file-review-store';
|
||||
import { giteaService } from '../services/gitea';
|
||||
|
||||
const feedbackRouter = new Hono();
|
||||
|
||||
// 全局实例
|
||||
let memoryStore: VectorMemoryStore | null = null;
|
||||
let learningSystem: LearningSystem | null = null;
|
||||
let reviewStore: FileReviewStore | null = null;
|
||||
|
||||
// 初始化反馈系统(记忆系统可选)
|
||||
export function initializeFeedbackSystem(store: FileReviewStore): void {
|
||||
// 保存store实例以供handlers重用,避免多实例状态不同步
|
||||
reviewStore = store;
|
||||
|
||||
// 记忆系统为可选功能
|
||||
if (config.review.qdrantUrl && config.review.enableMemory) {
|
||||
memoryStore = new VectorMemoryStore(config.review.qdrantUrl);
|
||||
learningSystem = new LearningSystem(memoryStore, reviewStore);
|
||||
|
||||
memoryStore.initialize().catch((err) => {
|
||||
console.error('Failed to initialize memory store:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 提交人工反馈
|
||||
feedbackRouter.post(
|
||||
'/finding/:findingId',
|
||||
zValidator(
|
||||
'json',
|
||||
z.object({
|
||||
approved: z.boolean().describe('是否批准该finding'),
|
||||
reason: z.string().optional().describe('反馈原因'),
|
||||
reviewer: z.string().optional().describe('审查者'),
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { findingId } = c.req.param();
|
||||
const { approved, reason } = c.req.valid('json');
|
||||
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
// 重用已初始化的store实例,避免多实例状态不同步
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run信息以获取owner和repo
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
if (!runDetails) {
|
||||
return c.json({ error: 'Run not found' }, 404);
|
||||
}
|
||||
|
||||
const { owner, repo } = runDetails.run;
|
||||
|
||||
// 原子幂等性保护:先标记finding为published(原子check-and-set)
|
||||
// 只有第一个请求会得到true,后续并发/重试请求会得到false
|
||||
// 这解决了read-check-write竞态:两个并发请求不会都发布评论
|
||||
const wasUnpublished = await reviewStore.markFindingPublished(
|
||||
finding.runId,
|
||||
finding.fingerprint
|
||||
);
|
||||
|
||||
if (!wasUnpublished) {
|
||||
// finding已被标记为published,但需验证是否真的发布成功
|
||||
// 场景:并发请求A正在发布时请求B到达,或请求A发布失败回滚后请求B重试
|
||||
// 检查是否存在已发布的comment记录来确认真实状态
|
||||
// 关键:必须通过fingerprint匹配,而非仅path+line,以区分同一位置的不同findings
|
||||
const publishedComment = runDetails.comments.find(
|
||||
(c) => c.status === 'published' && c.fingerprint === finding.fingerprint
|
||||
);
|
||||
|
||||
if (publishedComment) {
|
||||
// 确认已成功发布到Gitea(存在published comment record),返回幂等成功
|
||||
return c.json({
|
||||
success: true,
|
||||
message: '该finding已处理过',
|
||||
alreadyProcessed: true,
|
||||
learningApplied: false,
|
||||
published: true,
|
||||
});
|
||||
}
|
||||
// published标记存在但无published comment记录
|
||||
// 可能原因:1) 并发请求正在发布中 2) 之前发布失败并回滚
|
||||
// 不能声称成功,返回错误让用户稍后重试
|
||||
return c.json(
|
||||
{
|
||||
error: 'Finding approval in progress or previously failed. Please retry in a moment.',
|
||||
inProgress: true,
|
||||
},
|
||||
409
|
||||
); // 409 Conflict
|
||||
}
|
||||
|
||||
// 以下代码只会被第一个请求执行(wasUnpublished=true)
|
||||
|
||||
let learningApplied = false;
|
||||
|
||||
// 如果记忆系统启用,尝试执行学习和向量存储(可选功能,失败不阻止审批流程)
|
||||
if (memoryStore && learningSystem) {
|
||||
try {
|
||||
await memoryStore.storeFeedback(findingId, approved, reason || '', owner, repo);
|
||||
|
||||
if (approved) {
|
||||
await learningSystem.learnFromApproval(finding, owner, repo);
|
||||
} else {
|
||||
await learningSystem.learnFromFalsePositive(
|
||||
finding,
|
||||
reason || '人工标记为误报',
|
||||
owner,
|
||||
repo
|
||||
);
|
||||
}
|
||||
|
||||
learningApplied = true;
|
||||
} catch (memoryError) {
|
||||
// 记忆系统故障不应阻止人工审批操作
|
||||
console.error('Memory system operation failed (non-fatal):', memoryError);
|
||||
learningApplied = false;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// 如果批准,发布到Gitea(人工审批通过的问题应该通知开发者)
|
||||
if (approved) {
|
||||
const comment = `## 🔍 AI代码审查问题(人工确认)
|
||||
|
||||
**${finding.title}**
|
||||
|
||||
严重程度: ${finding.severity}
|
||||
置信度: ${(finding.confidence * 100).toFixed(0)}%
|
||||
|
||||
${finding.detail}
|
||||
|
||||
${finding.evidence ? `**证据:**\n\`\`\`\n${finding.evidence}\n\`\`\`` : ''}
|
||||
|
||||
${finding.suggestion ? `**建议:**\n${finding.suggestion}` : ''}
|
||||
|
||||
---
|
||||
_此问题已通过人工审批确认_`;
|
||||
|
||||
// 关键:区分Gitea发布失败和本地store失败,避免重复发布
|
||||
// 1. 先发布到Gitea,失败则回滚published标记
|
||||
// 2. 再写本地record,失败不回滚(因为Gitea已成功,重试不应重复发布)
|
||||
try {
|
||||
if (runDetails.run.eventType === 'pull_request' && runDetails.run.prNumber) {
|
||||
await giteaService.addPullRequestComment(owner, repo, runDetails.run.prNumber, comment);
|
||||
} else if (runDetails.run.commitSha) {
|
||||
await giteaService.addCommitComment(owner, repo, runDetails.run.commitSha, comment);
|
||||
}
|
||||
} catch (giteaError) {
|
||||
// Gitea API失败:回滚published状态,允许用户重试发布
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw giteaError;
|
||||
}
|
||||
|
||||
// Gitea发布成功,写入本地record
|
||||
// 关键权衡:如果record写入失败,必须回滚published标记以保持可恢复性
|
||||
// 代价:立即重试可能导致重复Gitea评论(罕见边缘情况,优于永久卡死)
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: comment,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 本地store失败:回滚published标记,允许用户重试
|
||||
// 如果用户立即重试,可能导致重复Gitea评论(可接受的权衡以避免永久卡死)
|
||||
console.error(
|
||||
'Failed to persist comment record after successful Gitea publish, rolling back:',
|
||||
storeError
|
||||
);
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw new Error(
|
||||
'Comment published to Gitea but failed to save locally. State rolled back, you may retry. Note: immediate retry may create duplicate comments.'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 拒绝(标记为误报):创建comment record以标记处理完成
|
||||
// 不发布到Gitea,但需要记录以使重试请求能识别已处理
|
||||
// 如果写入失败,回滚published标记以允许重试
|
||||
try {
|
||||
await reviewStore.addCommentRecord({
|
||||
runId: finding.runId,
|
||||
status: 'published',
|
||||
body: `REJECTED: ${finding.title} - ${reason || '人工标记为误报'}`,
|
||||
path: finding.path,
|
||||
line: finding.line,
|
||||
fingerprint: finding.fingerprint,
|
||||
});
|
||||
} catch (storeError) {
|
||||
// 拒绝record写入失败:回滚published标记,允许用户重试
|
||||
await reviewStore.unmarkFindingPublished(finding.runId, finding.fingerprint);
|
||||
throw storeError;
|
||||
}
|
||||
}
|
||||
|
||||
// finding已在开头原子标记为published,处理成功则保持published状态
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
message: approved ? '已标记为有效问题并发布到Gitea' : '已标记为误报',
|
||||
learningApplied,
|
||||
published: approved,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to process feedback:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to process feedback',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 获取待审批的findings
|
||||
feedbackRouter.get('/pending', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const limit = Number(c.req.query('limit') || '50');
|
||||
|
||||
try {
|
||||
const pendingFindings = await reviewStore.getPendingFindings(limit);
|
||||
|
||||
return c.json({
|
||||
findings: pendingFindings,
|
||||
total: pendingFindings.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pending findings:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch pending findings',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 获取finding详情
|
||||
feedbackRouter.get('/finding/:findingId', async (c) => {
|
||||
if (!reviewStore) {
|
||||
return c.json({ error: 'Feedback system not initialized' }, 503);
|
||||
}
|
||||
|
||||
const { findingId } = c.req.param();
|
||||
|
||||
try {
|
||||
const finding = await reviewStore.getFinding(findingId);
|
||||
|
||||
if (!finding) {
|
||||
return c.json({ error: 'Finding not found' }, 404);
|
||||
}
|
||||
|
||||
// 获取run详情以提供更多上下文
|
||||
const runDetails = await reviewStore.getRunDetails(finding.runId);
|
||||
|
||||
return c.json({
|
||||
finding,
|
||||
run: runDetails?.run,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch finding:', error);
|
||||
return c.json(
|
||||
{
|
||||
error: 'Failed to fetch finding',
|
||||
details: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { feedbackRouter };
|
||||
@@ -4,7 +4,6 @@
|
||||
*/
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { type ModelRole, modelRoleRepo } from '../db/repositories/model-role-repo';
|
||||
import {
|
||||
type CreateProviderInput,
|
||||
type UpdateProviderInput,
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
import { secretRepo } from '../db/repositories/secret-repo';
|
||||
import { settingsRepo } from '../db/repositories/settings-repo';
|
||||
import { llmGateway } from '../llm/gateway';
|
||||
import { MODEL_ROLES } from '../llm/types';
|
||||
import { tokenCounter } from '../review/context/token-counter';
|
||||
|
||||
export const llmConfigRouter = new Hono();
|
||||
@@ -92,14 +90,6 @@ llmConfigRouter.post('/providers', async (c) => {
|
||||
secretRepo.set(created.id, body.apiKey);
|
||||
}
|
||||
|
||||
const allProviders = providerRepo.list();
|
||||
if (allProviders.length === 1) {
|
||||
const modelRolesToBind: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
for (const role of modelRolesToBind) {
|
||||
modelRoleRepo.set(role, created.id, body.defaultModel);
|
||||
}
|
||||
}
|
||||
|
||||
return c.json(
|
||||
{
|
||||
id: created.id,
|
||||
@@ -153,12 +143,11 @@ llmConfigRouter.put('/providers/:id', async (c) => {
|
||||
|
||||
llmConfigRouter.delete('/providers/:id', (c) => {
|
||||
const id = c.req.param('id');
|
||||
const roles = modelRoleRepo.getRolesByProvider(id);
|
||||
const deleted = providerRepo.delete(id);
|
||||
if (!deleted) return c.json({ message: 'Provider not found' }, 404);
|
||||
|
||||
llmGateway.invalidateProvider(id);
|
||||
return c.json({ success: true, removedRoleAssignments: roles });
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── API Key Management ──────────────────────────────────────────────────
|
||||
@@ -186,52 +175,6 @@ llmConfigRouter.delete('/providers/:id/key', (c) => {
|
||||
return c.json({ success: true });
|
||||
});
|
||||
|
||||
// ── Role Assignments ────────────────────────────────────────────────────
|
||||
|
||||
llmConfigRouter.get('/roles', (c) => {
|
||||
const assignments = modelRoleRepo.list();
|
||||
|
||||
const allRoles = MODEL_ROLES.map((role) => {
|
||||
const assignment = assignments.find((a) => a.role === role);
|
||||
return assignment
|
||||
? {
|
||||
role: assignment.role,
|
||||
providerId: assignment.provider_id,
|
||||
providerName: assignment.provider_name,
|
||||
providerType: assignment.provider_type,
|
||||
model: assignment.model,
|
||||
}
|
||||
: { role, providerId: null, providerName: null, providerType: null, model: null };
|
||||
});
|
||||
|
||||
return c.json(allRoles);
|
||||
});
|
||||
|
||||
llmConfigRouter.put('/roles/:role', async (c) => {
|
||||
const role = c.req.param('role') as ModelRole;
|
||||
if (!MODEL_ROLES.includes(role)) {
|
||||
return c.json({ message: `Invalid role. Must be one of: ${MODEL_ROLES.join(', ')}` }, 400);
|
||||
}
|
||||
|
||||
const { providerId, model } = await c.req.json<{ providerId: string; model: string }>();
|
||||
if (!providerId || !model) {
|
||||
return c.json({ message: 'providerId and model are required' }, 400);
|
||||
}
|
||||
|
||||
const provider = providerRepo.getById(providerId);
|
||||
if (!provider) return c.json({ message: 'Provider not found' }, 404);
|
||||
|
||||
modelRoleRepo.set(role, providerId, model);
|
||||
|
||||
return c.json({
|
||||
role,
|
||||
providerId: provider.id,
|
||||
providerName: provider.name,
|
||||
providerType: provider.type,
|
||||
model,
|
||||
});
|
||||
});
|
||||
|
||||
// ── Connection Test ─────────────────────────────────────────────────────
|
||||
|
||||
llmConfigRouter.post('/providers/:id/test', async (c) => {
|
||||
|
||||
@@ -106,7 +106,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
// 从事件中提取必要信息
|
||||
const { pull_request: pullRequest, repository: repo } = body;
|
||||
|
||||
if (!pullRequest || !repo) {
|
||||
if (!pullRequest || !repo || !repo.owner?.login || !repo.name) {
|
||||
return c.json({ error: '无效的Webhook数据' }, 400);
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ async function handlePullRequestEvent(c: Context, body: any): Promise<Response>
|
||||
*/
|
||||
async function handlePullRequestClosed(c: Context, body: any): Promise<Response> {
|
||||
const { pull_request: pullRequest, repository: repo } = body;
|
||||
if (!pullRequest || !repo) {
|
||||
if (!pullRequest || !repo || !repo.owner?.login || !repo.name) {
|
||||
return c.json({ status: 'ignored', message: 'PR close 无效数据' }, 200);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ function createLegacySchema(dbPath: string): void {
|
||||
db.close();
|
||||
}
|
||||
|
||||
describe('migration 002 remove legacy review mode', () => {
|
||||
describe('legacy review and model role cleanup migrations', () => {
|
||||
let dbPath: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('migration 002 remove legacy review mode', () => {
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test('normalizes REVIEW_ENGINE and drops legacy model-role rows', () => {
|
||||
test('normalizes REVIEW_ENGINE and drops the old model-role assignment table', () => {
|
||||
initDatabase();
|
||||
const db = getDatabase();
|
||||
|
||||
@@ -118,15 +118,9 @@ describe('migration 002 remove legacy review mode', () => {
|
||||
.get('REVIEW_ENGINE') as { value: string } | null;
|
||||
expect(engineRow?.value).toBe('agent');
|
||||
|
||||
const roles = db
|
||||
.query('SELECT role FROM model_role_assignments ORDER BY role ASC')
|
||||
.all() as Array<{ role: string }>;
|
||||
expect(roles.map((row) => row.role)).toEqual(['planner']);
|
||||
|
||||
expect(() => {
|
||||
db.query(
|
||||
'INSERT INTO model_role_assignments (role, provider_id, model) VALUES (?, ?, ?)'
|
||||
).run('legacy', 'provider-1', 'gpt-4o');
|
||||
}).toThrow();
|
||||
const roleTable = db
|
||||
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get('model_role_assignments') as { name: string } | null;
|
||||
expect(roleTable).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
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 '../database';
|
||||
import { modelRoleRepo } from '../repositories/model-role-repo';
|
||||
import type { ModelRole } from '../repositories/model-role-repo';
|
||||
import { providerRepo } from '../repositories/provider-repo';
|
||||
import type { CreateProviderInput } from '../repositories/provider-repo';
|
||||
|
||||
describe('model-role-repo', () => {
|
||||
let dbPath: string;
|
||||
let providerId: string;
|
||||
const savedDbPath = process.env.DATABASE_PATH;
|
||||
|
||||
const providerInput: CreateProviderInput = {
|
||||
name: 'Test Provider',
|
||||
type: 'openai_compatible',
|
||||
baseUrl: 'https://api.example.com/v1',
|
||||
defaultModel: 'gpt-4o-mini',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
const tmpDir = join(tmpdir(), `db-test-${randomUUID()}`);
|
||||
mkdirSync(tmpDir, { recursive: true });
|
||||
dbPath = join(tmpDir, 'test.db');
|
||||
process.env.DATABASE_PATH = dbPath;
|
||||
initDatabase();
|
||||
|
||||
const created = providerRepo.create(providerInput);
|
||||
providerId = created.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
if (savedDbPath === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DATABASE_PATH');
|
||||
} else {
|
||||
process.env.DATABASE_PATH = savedDbPath;
|
||||
}
|
||||
try {
|
||||
if (existsSync(dbPath)) unlinkSync(dbPath);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-wal`)) unlinkSync(`${dbPath}-wal`);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
try {
|
||||
if (existsSync(`${dbPath}-shm`)) unlinkSync(`${dbPath}-shm`);
|
||||
} catch {
|
||||
/* ok */
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Set (upsert) ─────────────────────────────────────────────────
|
||||
|
||||
describe('set()', () => {
|
||||
test('creates a new role assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
const assignment = modelRoleRepo.getByRole('planner');
|
||||
expect(assignment).not.toBeNull();
|
||||
expect(assignment!.role).toBe('planner');
|
||||
expect(assignment!.provider_id).toBe(providerId);
|
||||
expect(assignment!.model).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
test('upserts: updates existing role assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
const assignment = modelRoleRepo.getByRole('planner');
|
||||
expect(assignment!.model).toBe('gpt-4o');
|
||||
});
|
||||
|
||||
test('can assign different roles', () => {
|
||||
const roles: ModelRole[] = ['planner', 'specialist', 'judge', 'embedding'];
|
||||
for (const role of roles) {
|
||||
modelRoleRepo.set(role, providerId, `model-for-${role}`);
|
||||
}
|
||||
|
||||
for (const role of roles) {
|
||||
const a = modelRoleRepo.getByRole(role);
|
||||
expect(a!.model).toBe(`model-for-${role}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GetByRole ────────────────────────────────────────────────────
|
||||
|
||||
describe('getByRole()', () => {
|
||||
test('returns null when no assignment exists', () => {
|
||||
expect(modelRoleRepo.getByRole('planner')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns the correct assignment', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
const a = modelRoleRepo.getByRole('planner');
|
||||
expect(a!.provider_id).toBe(providerId);
|
||||
expect(a!.model).toBe('gpt-4o');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('list()', () => {
|
||||
test('returns empty array when no assignments exist', () => {
|
||||
expect(modelRoleRepo.list()).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all assignments with provider info (JOIN)', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
const all = modelRoleRepo.list();
|
||||
expect(all).toHaveLength(2);
|
||||
|
||||
expect(all[0].provider_name).toBe('Test Provider');
|
||||
expect(all[0].provider_type).toBe('openai_compatible');
|
||||
});
|
||||
|
||||
test('results are ordered by role', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'model-a');
|
||||
modelRoleRepo.set('embedding', providerId, 'model-b');
|
||||
modelRoleRepo.set('planner', providerId, 'model-c');
|
||||
|
||||
const all = modelRoleRepo.list();
|
||||
const roles = all.map((a) => a.role);
|
||||
expect(roles).toEqual([...roles].sort());
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────
|
||||
|
||||
describe('delete()', () => {
|
||||
test('deletes existing assignment, returns true', () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
expect(modelRoleRepo.delete('planner')).toBe(true);
|
||||
expect(modelRoleRepo.getByRole('planner')).toBeNull();
|
||||
});
|
||||
|
||||
test('returns false for non-existent role', () => {
|
||||
expect(modelRoleRepo.delete('planner')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── GetRolesByProvider ───────────────────────────────────────────
|
||||
|
||||
describe('getRolesByProvider()', () => {
|
||||
test('returns empty array when no roles assigned', () => {
|
||||
expect(modelRoleRepo.getRolesByProvider(providerId)).toEqual([]);
|
||||
});
|
||||
|
||||
test('returns all roles assigned to a provider', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
modelRoleRepo.set('judge', providerId, 'gpt-4o');
|
||||
|
||||
const roles = modelRoleRepo.getRolesByProvider(providerId);
|
||||
expect(roles).toHaveLength(3);
|
||||
expect(roles).toContain('specialist');
|
||||
expect(roles).toContain('planner');
|
||||
expect(roles).toContain('judge');
|
||||
});
|
||||
|
||||
test('does not return roles assigned to other providers', () => {
|
||||
const p2 = providerRepo.create({
|
||||
...providerInput,
|
||||
name: 'Other Provider',
|
||||
type: 'anthropic',
|
||||
});
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', p2.id, 'claude-3-5-sonnet');
|
||||
|
||||
const roles1 = modelRoleRepo.getRolesByProvider(providerId);
|
||||
expect(roles1).toEqual(['specialist']);
|
||||
|
||||
const roles2 = modelRoleRepo.getRolesByProvider(p2.id);
|
||||
expect(roles2).toEqual(['planner']);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── CASCADE on provider delete ───────────────────────────────────
|
||||
|
||||
describe('foreign key constraint', () => {
|
||||
test('cannot delete provider while role assignments exist (no CASCADE)', () => {
|
||||
modelRoleRepo.set('specialist', providerId, 'gpt-4o-mini');
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o');
|
||||
|
||||
// FK constraint prevents delete — must remove assignments first
|
||||
expect(() => providerRepo.delete(providerId)).toThrow();
|
||||
|
||||
// Clean up assignments first, then delete succeeds
|
||||
modelRoleRepo.delete('specialist');
|
||||
modelRoleRepo.delete('planner');
|
||||
expect(providerRepo.delete(providerId)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,9 @@ import { dirname, resolve } from 'node:path';
|
||||
import { migration001Init } from './migrations/001_init';
|
||||
import { migration002RemoveLegacyReviewMode } from './migrations/002_remove_legacy_review_mode';
|
||||
import { migration003RepositoryReviewPrompts } from './migrations/003_repository_review_prompts';
|
||||
import { migration004RemoveEmbeddingRole } from './migrations/004_remove_embedding_role';
|
||||
import { migration005AgentTranscripts } from './migrations/005_agent_transcripts';
|
||||
import { migration006DropLegacyAssignments } from './migrations/006_drop_legacy_assignments';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -31,6 +34,9 @@ const MIGRATIONS: Migration[] = [
|
||||
migration001Init,
|
||||
migration002RemoveLegacyReviewMode,
|
||||
migration003RepositoryReviewPrompts,
|
||||
migration004RemoveEmbeddingRole,
|
||||
migration005AgentTranscripts,
|
||||
migration006DropLegacyAssignments,
|
||||
];
|
||||
|
||||
const REPOSITORY_REVIEW_PROMPTS_TABLE = 'repository_review_prompts';
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
* Creates tables:
|
||||
* - llm_providers: Provider instance configuration
|
||||
* - llm_secrets: Encrypted API key storage
|
||||
* - model_role_assignments: Business role → provider+model mapping
|
||||
* - system_settings: Generic KV settings store
|
||||
*/
|
||||
|
||||
@@ -48,22 +47,6 @@ export const migration001Init: Migration = {
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Table 3: model_role_assignments ─────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE model_role_assignments (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge',
|
||||
'embedding'
|
||||
)),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
// ── Table 4: system_settings ────────────────────────────────────────
|
||||
db.exec(`
|
||||
CREATE TABLE system_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
|
||||
@@ -1,34 +1,12 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
const ALLOWED_ROLES = "'planner','specialist','judge','embedding'";
|
||||
|
||||
export const migration002RemoveLegacyReviewMode: Migration = {
|
||||
version: 2,
|
||||
name: 'remove_legacy_review_mode',
|
||||
|
||||
up(db: Database): void {
|
||||
up(db): void {
|
||||
db.exec(
|
||||
"UPDATE system_settings SET value = 'agent' WHERE key = 'REVIEW_ENGINE' AND value NOT IN ('agent','codex')"
|
||||
);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE model_role_assignments_new (
|
||||
role TEXT PRIMARY KEY CHECK (role IN (${ALLOWED_ROLES})),
|
||||
provider_id TEXT NOT NULL REFERENCES llm_providers(id),
|
||||
model TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
INSERT INTO model_role_assignments_new (role, provider_id, model, updated_at)
|
||||
SELECT role, provider_id, model, updated_at
|
||||
FROM model_role_assignments
|
||||
WHERE role IN (${ALLOWED_ROLES})
|
||||
`);
|
||||
|
||||
db.exec('DROP TABLE model_role_assignments');
|
||||
db.exec('ALTER TABLE model_role_assignments_new RENAME TO model_role_assignments');
|
||||
},
|
||||
};
|
||||
|
||||
8
src/db/migrations/004_remove_embedding_role.ts
Normal file
8
src/db/migrations/004_remove_embedding_role.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration004RemoveEmbeddingRole: Migration = {
|
||||
version: 4,
|
||||
name: 'remove_embedding_role',
|
||||
|
||||
up(): void {},
|
||||
};
|
||||
88
src/db/migrations/005_agent_transcripts.ts
Normal file
88
src/db/migrations/005_agent_transcripts.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration005AgentTranscripts: Migration = {
|
||||
version: 5,
|
||||
name: 'add_agent_transcripts',
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
parent_invocation_id TEXT,
|
||||
agent_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
final_result_json TEXT,
|
||||
error_json TEXT,
|
||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_tool_calls (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
message_id TEXT REFERENCES agent_messages(id) ON DELETE SET NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed')),
|
||||
arguments_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT,
|
||||
error_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
UNIQUE(session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_invocations (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||
child_session_id TEXT REFERENCES agent_sessions(id) ON DELETE SET NULL,
|
||||
sequence INTEGER NOT NULL,
|
||||
agent_type TEXT NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('running', 'completed', 'failed', 'cancelled')),
|
||||
input_json TEXT NOT NULL DEFAULT '{}',
|
||||
result_json TEXT,
|
||||
error_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
completed_at TEXT,
|
||||
UNIQUE(parent_session_id, sequence)
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_sessions_parent ON agent_sessions(parent_session_id, created_at)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_messages_session_sequence ON agent_messages(session_id, sequence)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_tool_calls_session_sequence ON agent_tool_calls(session_id, sequence)'
|
||||
);
|
||||
db.exec(
|
||||
'CREATE INDEX IF NOT EXISTS idx_agent_invocations_parent_sequence ON agent_invocations(parent_session_id, sequence)'
|
||||
);
|
||||
},
|
||||
};
|
||||
11
src/db/migrations/006_drop_legacy_assignments.ts
Normal file
11
src/db/migrations/006_drop_legacy_assignments.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { Database } from 'bun:sqlite';
|
||||
import type { Migration } from '../database';
|
||||
|
||||
export const migration006DropLegacyAssignments: Migration = {
|
||||
version: 6,
|
||||
name: 'drop_model_role_assignments',
|
||||
|
||||
up(db: Database): void {
|
||||
db.exec('DROP TABLE IF EXISTS model_role_assignments');
|
||||
},
|
||||
};
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Repository for model_role_assignments table.
|
||||
* Maps business roles (planner, specialist, judge, embedding)
|
||||
* to specific provider + model combinations.
|
||||
*/
|
||||
|
||||
import { getDatabase } from '../database';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
export interface RoleAssignmentRow {
|
||||
role: ModelRole;
|
||||
provider_id: string;
|
||||
model: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Enriched role assignment with provider metadata (for API responses). */
|
||||
export interface RoleAssignmentWithProvider extends RoleAssignmentRow {
|
||||
provider_name: string;
|
||||
provider_type: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repository
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const modelRoleRepo = {
|
||||
/**
|
||||
* List all role assignments with provider info.
|
||||
*/
|
||||
list(): RoleAssignmentWithProvider[] {
|
||||
const db = getDatabase();
|
||||
return db
|
||||
.query(
|
||||
`SELECT
|
||||
r.role,
|
||||
r.provider_id,
|
||||
r.model,
|
||||
r.updated_at,
|
||||
p.name AS provider_name,
|
||||
p.type AS provider_type
|
||||
FROM model_role_assignments r
|
||||
JOIN llm_providers p ON r.provider_id = p.id
|
||||
ORDER BY r.role`
|
||||
)
|
||||
.all() as RoleAssignmentWithProvider[];
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the assignment for a specific role.
|
||||
*/
|
||||
getByRole(role: ModelRole): RoleAssignmentRow | null {
|
||||
const db = getDatabase();
|
||||
return (
|
||||
(db
|
||||
.query('SELECT * FROM model_role_assignments WHERE role = ?')
|
||||
.get(role) as RoleAssignmentRow) || null
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Set (upsert) a role → provider+model mapping.
|
||||
*/
|
||||
set(role: ModelRole, providerId: string, model: string): void {
|
||||
const db = getDatabase();
|
||||
db.query(
|
||||
`INSERT INTO model_role_assignments (role, provider_id, model, updated_at)
|
||||
VALUES (?, ?, ?, datetime('now'))
|
||||
ON CONFLICT(role) DO UPDATE SET
|
||||
provider_id = excluded.provider_id,
|
||||
model = excluded.model,
|
||||
updated_at = datetime('now')`
|
||||
).run(role, providerId, model);
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a role assignment.
|
||||
*/
|
||||
delete(role: ModelRole): boolean {
|
||||
const db = getDatabase();
|
||||
const result = db.query('DELETE FROM model_role_assignments WHERE role = ?').run(role);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all roles assigned to a specific provider (used when disabling/deleting a provider).
|
||||
*/
|
||||
getRolesByProvider(providerId: string): ModelRole[] {
|
||||
const db = getDatabase();
|
||||
return db
|
||||
.query('SELECT role FROM model_role_assignments WHERE provider_id = ?')
|
||||
.all(providerId)
|
||||
.map((row: any) => row.role as ModelRole);
|
||||
},
|
||||
};
|
||||
@@ -130,7 +130,7 @@ export const providerRepo = {
|
||||
|
||||
/**
|
||||
* Delete a provider by ID. Returns true if deleted.
|
||||
* CASCADE will also delete the associated secret and role assignments.
|
||||
* CASCADE will also delete the associated secret.
|
||||
*/
|
||||
delete(id: string): boolean {
|
||||
const db = getDatabase();
|
||||
|
||||
14
src/index.ts
14
src/index.ts
@@ -3,8 +3,8 @@ import { serveStatic } from 'hono/bun';
|
||||
import { jwt } from 'hono/jwt';
|
||||
import config, { configManager } from './config';
|
||||
import { adminController } from './controllers/admin';
|
||||
import { agentsRouter } from './controllers/agents';
|
||||
import { configRouter } from './controllers/config';
|
||||
import { feedbackRouter, initializeFeedbackSystem } from './controllers/feedback';
|
||||
import { llmConfigRouter } from './controllers/llm-config';
|
||||
import { handleGiteaWebhook } from './controllers/review';
|
||||
import { initMasterKey } from './crypto/secrets';
|
||||
@@ -60,9 +60,9 @@ adminProtected.use('/*', (c, next) => {
|
||||
return jwtMiddleware(c, next);
|
||||
});
|
||||
adminProtected.route('/', adminController.protectedRoutes);
|
||||
adminProtected.route('/feedback', feedbackRouter);
|
||||
adminProtected.route('/config', configRouter);
|
||||
adminProtected.route('/llm', llmConfigRouter);
|
||||
adminProtected.route('/agents', agentsRouter);
|
||||
app.route('/admin/api', adminProtected);
|
||||
|
||||
// --- 前端静态文件服务 ---
|
||||
@@ -88,16 +88,6 @@ codexEngine.start().catch((error) => {
|
||||
// 启动清理调度器(定期清理过期 mirror/workspace 目录)
|
||||
cleanupScheduler.start();
|
||||
|
||||
// 初始化反馈系统(总是初始化,记忆系统可选)
|
||||
const reviewStore = reviewEngine.getStore();
|
||||
initializeFeedbackSystem(reviewStore);
|
||||
|
||||
if (config.review.enableMemory) {
|
||||
console.log('✅ 反馈系统已初始化(含向量记忆)');
|
||||
} else {
|
||||
console.log('✅ 反馈系统已初始化(不含向量记忆)');
|
||||
}
|
||||
|
||||
export default {
|
||||
port,
|
||||
fetch: app.fetch,
|
||||
|
||||
@@ -5,7 +5,6 @@ import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { initMasterKey } from '../../crypto/secrets';
|
||||
import { closeDatabase, initDatabase } from '../../db/database';
|
||||
import { modelRoleRepo } from '../../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../../db/repositories/provider-repo';
|
||||
import type { CreateProviderInput } from '../../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../../db/repositories/secret-repo';
|
||||
@@ -74,72 +73,6 @@ describe('LLMGateway', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── chatForRole: Error Cases ──────────────────────────────────────
|
||||
|
||||
describe('chatForRole() — error handling', () => {
|
||||
test('throws LLMNoProviderError when role is not assigned', async () => {
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
expect(e.role).toBe('planner');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMError when provider is disabled', async () => {
|
||||
providerRepo.update(providerId, { isEnabled: false });
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMError');
|
||||
expect(e.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMAuthError when no API key configured', async () => {
|
||||
secretRepo.delete(providerId);
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
|
||||
try {
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMAuthError');
|
||||
expect(e.message).toContain('No API key');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMError when provider not found after role assignment manually deleted', async () => {
|
||||
modelRoleRepo.set('planner', providerId, 'gpt-4o-mini');
|
||||
// Must remove assignments before deleting provider (no CASCADE on model_role_assignments)
|
||||
modelRoleRepo.delete('planner');
|
||||
secretRepo.delete(providerId);
|
||||
providerRepo.delete(providerId);
|
||||
|
||||
// Re-create assignment pointing to non-existent provider
|
||||
// (simulating stale data)
|
||||
try {
|
||||
// No assignment exists now, so this throws LLMNoProviderError
|
||||
await gateway.chatForRole('planner', {
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── chatDirect: Error Cases ──────────────────────────────────────
|
||||
|
||||
describe('chatDirect() — error handling', () => {
|
||||
@@ -155,18 +88,34 @@ describe('LLMGateway', () => {
|
||||
expect(e.message).toContain('not found');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── embedForRole: Error Cases ────────────────────────────────────
|
||||
test('throws LLMError when provider is disabled', async () => {
|
||||
providerRepo.update(providerId, { isEnabled: false });
|
||||
|
||||
describe('embedForRole() — error handling', () => {
|
||||
test('throws LLMNoProviderError when embedding role not assigned', async () => {
|
||||
try {
|
||||
await gateway.embedForRole(['text']);
|
||||
await gateway.chatDirect(providerId, {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMNoProviderError');
|
||||
expect(e.role).toBe('embedding');
|
||||
expect(e.name).toBe('LLMError');
|
||||
expect(e.message).toContain('disabled');
|
||||
}
|
||||
});
|
||||
|
||||
test('throws LLMAuthError when no API key configured', async () => {
|
||||
secretRepo.delete(providerId);
|
||||
|
||||
try {
|
||||
await gateway.chatDirect(providerId, {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
});
|
||||
expect(true).toBe(false);
|
||||
} catch (e: any) {
|
||||
expect(e.name).toBe('LLMAuthError');
|
||||
expect(e.message).toContain('No API key');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
112
src/llm/e2e-mock.ts
Normal file
112
src/llm/e2e-mock.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { MainAgentModelClient } from '../agent-kernel/loop';
|
||||
import type { LLMChatRequest, LLMChatResponse, LLMToolCall } from './types';
|
||||
|
||||
export interface MockLLMScriptTurn {
|
||||
content?: string | null;
|
||||
toolCalls?: LLMToolCall[];
|
||||
finishReason?: LLMChatResponse['finishReason'];
|
||||
usage?: LLMChatResponse['usage'];
|
||||
}
|
||||
|
||||
export interface MockLLMScriptStep {
|
||||
session: string;
|
||||
turn: MockLLMScriptTurn;
|
||||
}
|
||||
|
||||
export interface MockLLMCallRecord {
|
||||
session: string;
|
||||
request: LLMChatRequest;
|
||||
response: LLMChatResponse;
|
||||
}
|
||||
|
||||
export interface ScriptedMockLLMOptions {
|
||||
steps: MockLLMScriptStep[];
|
||||
resolveSession?: (request: LLMChatRequest) => string;
|
||||
}
|
||||
|
||||
const DEFAULT_USAGE: LLMChatResponse['usage'] = {
|
||||
promptTokens: 1,
|
||||
completionTokens: 1,
|
||||
totalTokens: 2,
|
||||
};
|
||||
|
||||
function cloneTurn(turn: MockLLMScriptTurn): MockLLMScriptTurn {
|
||||
return {
|
||||
content: turn.content ?? null,
|
||||
toolCalls: structuredClone(turn.toolCalls ?? []),
|
||||
finishReason: turn.finishReason,
|
||||
usage: turn.usage ? structuredClone(turn.usage) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function scriptedTurn(turn: MockLLMScriptTurn): MockLLMScriptTurn {
|
||||
return cloneTurn(turn);
|
||||
}
|
||||
|
||||
export class ScriptedMockLLM implements MainAgentModelClient {
|
||||
private readonly resolveSession: (request: LLMChatRequest) => string;
|
||||
private readonly queues: Map<string, MockLLMScriptTurn[]>;
|
||||
readonly calls: MockLLMCallRecord[] = [];
|
||||
|
||||
constructor(options: ScriptedMockLLMOptions) {
|
||||
this.resolveSession =
|
||||
options.resolveSession ??
|
||||
((request) => {
|
||||
const userMessage = [...request.messages]
|
||||
.reverse()
|
||||
.find((message) => message.role === 'user');
|
||||
const marker = userMessage?.content.match(/^\[session:([^\]]+)\]/);
|
||||
return marker?.[1] ?? 'main';
|
||||
});
|
||||
|
||||
this.queues = new Map();
|
||||
for (const step of options.steps) {
|
||||
const queue = this.queues.get(step.session) ?? [];
|
||||
queue.push(cloneTurn(step.turn));
|
||||
this.queues.set(step.session, queue);
|
||||
}
|
||||
}
|
||||
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
const session = this.resolveSession(request);
|
||||
const queue = this.queues.get(session) ?? [];
|
||||
const turn = queue.shift();
|
||||
this.queues.set(session, queue);
|
||||
|
||||
if (!turn) {
|
||||
throw new Error(`No scripted mock turn queued for session '${session}'`);
|
||||
}
|
||||
|
||||
const response: LLMChatResponse = {
|
||||
content: turn.content ?? null,
|
||||
toolCalls: turn.toolCalls ?? [],
|
||||
finishReason:
|
||||
turn.finishReason ?? ((turn.toolCalls?.length ?? 0) > 0 ? 'tool_calls' : 'stop'),
|
||||
usage: turn.usage ?? DEFAULT_USAGE,
|
||||
};
|
||||
|
||||
this.calls.push({
|
||||
session,
|
||||
request: structuredClone(request),
|
||||
response: structuredClone(response),
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
assertExhausted(): void {
|
||||
for (const [session, queue] of this.queues.entries()) {
|
||||
if (queue.length > 0) {
|
||||
throw new Error(
|
||||
`Scripted mock still has ${queue.length} pending turn(s) for session '${session}'`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toolCallSequence(session?: string): string[] {
|
||||
return this.calls
|
||||
.filter((record) => (session ? record.session === session : true))
|
||||
.flatMap((record) => record.response.toolCalls.map((toolCall) => toolCall.name));
|
||||
}
|
||||
}
|
||||
@@ -79,14 +79,3 @@ export class LLMConnectionError extends LLMError {
|
||||
this.name = 'LLMConnectionError';
|
||||
}
|
||||
}
|
||||
|
||||
/** No provider is configured for the requested role. */
|
||||
export class LLMNoProviderError extends LLMError {
|
||||
readonly role: string;
|
||||
|
||||
constructor(role: string) {
|
||||
super(`No provider configured for role '${role}'`, 'gateway');
|
||||
this.name = 'LLMNoProviderError';
|
||||
this.role = role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
* LLM Gateway — the sole entry point for all business-layer LLM calls.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Look up model_role_assignments → provider_id + model for a given role
|
||||
* 2. Load (or cache) LLMProvider instances with decrypted API keys
|
||||
* 3. Route chat() calls to the correct adapter
|
||||
* 4. Invalidate cache when provider config changes via UI
|
||||
* 5. Concurrency control + retry-with-backoff for resilience
|
||||
* 1. Load (or cache) LLMProvider instances with decrypted API keys
|
||||
* 2. Route chat() calls to the correct adapter
|
||||
* 3. Invalidate cache when provider config changes via UI
|
||||
* 4. Concurrency control + retry-with-backoff for resilience
|
||||
*/
|
||||
|
||||
import { type ModelRole, modelRoleRepo } from '../db/repositories/model-role-repo';
|
||||
import { providerRepo } from '../db/repositories/provider-repo';
|
||||
import { secretRepo } from '../db/repositories/secret-repo';
|
||||
import { LLMAuthError, LLMError, LLMNoProviderError } from './errors';
|
||||
import { LLMAuthError, LLMError } from './errors';
|
||||
import { createAnthropicProvider } from './providers/anthropic';
|
||||
import type { LLMProvider } from './providers/base';
|
||||
import { createGeminiProvider } from './providers/gemini';
|
||||
@@ -53,28 +51,6 @@ export class LLMGateway {
|
||||
this.retryOptions = retryOptions ?? this.retryOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call LLM by business role. The role determines which provider + model to use.
|
||||
* The `model` field in the request is ignored — it's resolved from the DB assignment.
|
||||
*/
|
||||
async chatForRole(
|
||||
role: ModelRole,
|
||||
request: Omit<LLMChatRequest, 'model'>
|
||||
): Promise<LLMChatResponse> {
|
||||
const assignment = modelRoleRepo.getByRole(role);
|
||||
if (!assignment) throw new LLMNoProviderError(role);
|
||||
|
||||
return withResilience(
|
||||
this.semaphore,
|
||||
() => {
|
||||
const provider = this.getOrCreateProvider(assignment.provider_id);
|
||||
return provider.chat({ ...request, model: assignment.model });
|
||||
},
|
||||
this.retryOptions,
|
||||
role
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct call to a specific provider (used for connection testing).
|
||||
*/
|
||||
@@ -90,30 +66,6 @@ export class LLMGateway {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding via the provider assigned to the 'embedding' role.
|
||||
*/
|
||||
async embedForRole(texts: string[]): Promise<number[][]> {
|
||||
const assignment = modelRoleRepo.getByRole('embedding');
|
||||
if (!assignment) throw new LLMNoProviderError('embedding');
|
||||
|
||||
return withResilience(
|
||||
this.semaphore,
|
||||
() => {
|
||||
const provider = this.getOrCreateProvider(assignment.provider_id);
|
||||
if (!provider.embed) {
|
||||
throw new LLMError(
|
||||
`Provider '${provider.type}' does not support embeddings`,
|
||||
provider.type
|
||||
);
|
||||
}
|
||||
return provider.embed(texts);
|
||||
},
|
||||
this.retryOptions,
|
||||
'embedding'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached provider instance (call when config/key changes via UI).
|
||||
*/
|
||||
|
||||
117
src/llm/runtime-e2e-mock.ts
Normal file
117
src/llm/runtime-e2e-mock.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { MainAgentModelClient } from '../agent-kernel/loop';
|
||||
import type { LLMChatRequest, LLMChatResponse, LLMToolCall } from './types';
|
||||
|
||||
const DEFAULT_USAGE = {
|
||||
promptTokens: 8,
|
||||
completionTokens: 8,
|
||||
totalTokens: 16,
|
||||
} as const;
|
||||
|
||||
function hasTool(request: LLMChatRequest, name: string): boolean {
|
||||
return (request.tools ?? []).some((tool) => tool.name === name);
|
||||
}
|
||||
|
||||
function toolResultNames(request: LLMChatRequest): string[] {
|
||||
const toolNameById = new Map<string, string>();
|
||||
const completed: string[] = [];
|
||||
|
||||
for (const message of request.messages) {
|
||||
if (message.role === 'assistant') {
|
||||
for (const call of message.toolCalls ?? []) {
|
||||
toolNameById.set(call.id, call.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (message.role !== 'tool' || !message.toolCallId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
JSON.parse(message.content);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = toolNameById.get(message.toolCallId);
|
||||
if (toolName) {
|
||||
completed.push(toolName);
|
||||
}
|
||||
}
|
||||
|
||||
return completed;
|
||||
}
|
||||
|
||||
function toolCall(id: string, name: string, args: Record<string, unknown>): LLMToolCall {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
arguments: JSON.stringify(args),
|
||||
};
|
||||
}
|
||||
|
||||
function response(content: string | null, toolCalls: LLMToolCall[] = []): LLMChatResponse {
|
||||
return {
|
||||
content,
|
||||
toolCalls,
|
||||
finishReason: toolCalls.length > 0 ? 'tool_calls' : 'stop',
|
||||
usage: DEFAULT_USAGE,
|
||||
};
|
||||
}
|
||||
|
||||
export class RuntimeE2EMockLLM implements MainAgentModelClient {
|
||||
async chat(request: LLMChatRequest): Promise<LLMChatResponse> {
|
||||
const isMain = hasTool(request, 'spawn_subagent') && hasTool(request, 'submit_review_findings');
|
||||
const names = toolResultNames(request);
|
||||
|
||||
if (isMain) {
|
||||
if (!names.includes('read_file')) {
|
||||
return response(null, [
|
||||
toolCall('main-read-file', 'read_file', { path: 'src/user-handler.ts' }),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('spawn_subagent')) {
|
||||
return response(null, [
|
||||
toolCall('main-spawn-subagent', 'spawn_subagent', {
|
||||
description: '检查高风险模式并提供证据',
|
||||
prompt: '请先搜索再读取目标文件,确认是否存在高风险问题并给出简短结论。',
|
||||
}),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('submit_review_findings')) {
|
||||
return response(null, [
|
||||
toolCall('main-submit-findings', 'submit_review_findings', {
|
||||
summaryMarkdown: '发现高风险安全问题,建议阻断合并并修复。',
|
||||
findings: [
|
||||
{
|
||||
fingerprint: 'security:src/user-handler.ts:107:avoid-eval',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.95,
|
||||
path: 'src/user-handler.ts',
|
||||
line: 107,
|
||||
title: '不安全的动态代码执行',
|
||||
detail: '直接对外部输入执行 eval 可能导致远程代码执行。',
|
||||
evidence: 'const config = eval(input.config);',
|
||||
suggestion: '移除 eval,改用白名单解析器或结构化配置。',
|
||||
},
|
||||
],
|
||||
}),
|
||||
]);
|
||||
}
|
||||
return response('E2E mock review completed.');
|
||||
}
|
||||
|
||||
if (!names.includes('search_code')) {
|
||||
return response(null, [
|
||||
toolCall('sub-search-code', 'search_code', { query: 'eval(', maxResults: 5 }),
|
||||
]);
|
||||
}
|
||||
if (!names.includes('read_file')) {
|
||||
return response(null, [
|
||||
toolCall('sub-read-file', 'read_file', { path: 'src/user-handler.ts' }),
|
||||
]);
|
||||
}
|
||||
return response('子代理确认发现高风险 eval 用法。');
|
||||
}
|
||||
}
|
||||
@@ -5,21 +5,6 @@
|
||||
* Provider adapters translate to/from these types.
|
||||
*/
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Model Role
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Business role that maps to a specific provider + model via DB config. */
|
||||
export type ModelRole = 'planner' | 'specialist' | 'judge' | 'embedding';
|
||||
|
||||
/** All valid model roles. */
|
||||
export const MODEL_ROLES: readonly ModelRole[] = [
|
||||
'planner',
|
||||
'specialist',
|
||||
'judge',
|
||||
'embedding',
|
||||
] as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Type
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
172
src/review-agent/__tests__/deterministic-publish-adapter.test.ts
Normal file
172
src/review-agent/__tests__/deterministic-publish-adapter.test.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { applyDeterministicPublishAdapter } from '../deterministic-publish-adapter';
|
||||
import type { ReviewAgentFinding } from '../tools';
|
||||
|
||||
function makeFinding(overrides: Partial<ReviewAgentFinding> = {}): ReviewAgentFinding {
|
||||
return {
|
||||
fingerprint: '',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
confidence: 0.9,
|
||||
path: 'src/app.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Unsanitized input',
|
||||
evidence: 'db.query(input)',
|
||||
suggestion: 'Use parameterized queries',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function expectedFingerprint(category: string, path: string, line: number, title: string): string {
|
||||
return createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
}
|
||||
|
||||
describe('SHA256 fingerprint generation', () => {
|
||||
it('produces consistent 24-char hex fingerprint', () => {
|
||||
const fp = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp).toHaveLength(24);
|
||||
expect(fp).toMatch(/^[0-9a-f]{24}$/);
|
||||
});
|
||||
|
||||
it('produces different fingerprints for different inputs', () => {
|
||||
const fp1 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
const fp2 = expectedFingerprint('correctness', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp1).not.toBe(fp2);
|
||||
});
|
||||
|
||||
it('produces same fingerprint for same inputs', () => {
|
||||
const fp1 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
const fp2 = expectedFingerprint('security', 'src/app.ts', 42, 'SQL injection');
|
||||
expect(fp1).toBe(fp2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDeterministicPublishAdapter deduplication', () => {
|
||||
it('dedupes findings with identical fingerprints keeping higher rank', async () => {
|
||||
const finding1 = makeFinding({ severity: 'low', confidence: 0.5, fingerprint: 'dup-fp' });
|
||||
const finding2 = makeFinding({ severity: 'high', confidence: 0.9, fingerprint: 'dup-fp' });
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({ findings: [], comments: [] }),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'test-run',
|
||||
submission: { summaryMarkdown: 'test', findings: [finding1, finding2] },
|
||||
});
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
});
|
||||
|
||||
it('dedupes findings with same path/line/title (similarity key)', async () => {
|
||||
const finding1 = makeFinding({
|
||||
path: 'a.ts',
|
||||
line: 10,
|
||||
title: 'Bug',
|
||||
severity: 'medium',
|
||||
fingerprint: 'fp1',
|
||||
});
|
||||
const finding2 = makeFinding({
|
||||
path: 'a.ts',
|
||||
line: 10,
|
||||
title: 'Bug',
|
||||
severity: 'high',
|
||||
fingerprint: 'fp2',
|
||||
});
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({ findings: [], comments: [] }),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'test-run',
|
||||
submission: { summaryMarkdown: 'test', findings: [finding1, finding2] },
|
||||
});
|
||||
|
||||
expect(result.findings).toHaveLength(1);
|
||||
expect(result.findings[0].severity).toBe('high');
|
||||
});
|
||||
|
||||
it('fingerprint migration: legacy colon-format matches new JSON tuple format', () => {
|
||||
const category = 'security';
|
||||
const path = 'src/auth.ts';
|
||||
const line = 42;
|
||||
const title = 'SQL injection';
|
||||
const legacy = createHash('sha256')
|
||||
.update(`${category}:${path}:${line}:${title}`)
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
const modern = createHash('sha256')
|
||||
.update(JSON.stringify([category, path, line, title]))
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
expect(legacy).not.toBe(modern);
|
||||
});
|
||||
|
||||
it('preserves published=true when migrating from legacy to modern fingerprint', async () => {
|
||||
const legacy = createHash('sha256')
|
||||
.update('security:src/auth.ts:42:SQL injection')
|
||||
.digest('hex')
|
||||
.slice(0, 24);
|
||||
|
||||
const store = {
|
||||
getRunDetails: async () => ({
|
||||
findings: [
|
||||
{
|
||||
id: 'old-1',
|
||||
runId: 'run-migrate',
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
path: 'src/auth.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Use parameterized queries.',
|
||||
evidence: '',
|
||||
suggestion: '',
|
||||
confidence: 0.9,
|
||||
fingerprint: legacy,
|
||||
published: true,
|
||||
},
|
||||
],
|
||||
comments: [],
|
||||
}),
|
||||
addFindings: async () => {},
|
||||
addCommentRecord: async () => {},
|
||||
} as any;
|
||||
|
||||
const result = await applyDeterministicPublishAdapter({
|
||||
store,
|
||||
runId: 'run-migrate',
|
||||
submission: {
|
||||
summaryMarkdown: 'Found SQL injection.',
|
||||
findings: [
|
||||
{
|
||||
category: 'security',
|
||||
severity: 'high',
|
||||
path: 'src/auth.ts',
|
||||
line: 42,
|
||||
title: 'SQL injection',
|
||||
detail: 'Use parameterized queries.',
|
||||
evidence: '',
|
||||
suggestion: '',
|
||||
confidence: 0.9,
|
||||
fingerprint: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(result.findings[0].published).toBe(true);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user