feat: 新增 Agent OS 管理与运行时能力

This commit is contained in:
2026-03-01 22:33:55 +08:00
parent 84b0209b03
commit 8f23ec49cf
58 changed files with 4432 additions and 46 deletions
+11
View File
@@ -4,6 +4,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
# Webhook API - Generate a secure key for production
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
# Admin Console key for Agent OS endpoints and dashboard
ADMIN_CONSOLE_KEY="sk_admin_your_secure_admin_key_min_32_chars"
# Agent OS model runtime (OpenAI-compatible)
# BigModel GLM example endpoint:
# https://open.bigmodel.cn/api/coding/paas/v4/chat/completions
AGENT_OS_MODEL_ENDPOINT="https://open.bigmodel.cn/api/coding/paas/v4/chat/completions"
AGENT_OS_MODEL_NAME="glm-4.7"
AGENT_OS_MODEL_API_KEY="sk_your_model_api_key"
AGENT_OS_MODEL_TIMEOUT_MS="30000"
# n8n AI Search Webhook
N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
+1 -1
View File
@@ -9,7 +9,7 @@
"LOG_LEVEL": "error",
"DISABLE_CONSOLE_OUTPUT": "true",
"N8N_API_URL": "https://n8n.mzaxd.fun",
"N8N_API_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJmMzliZjQ1NC1kMjliLTQ2OGQtYjA4ZC0yYTE1YjJlODA0NzkiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMWM2YmQ2NTEtMWIwOC00YTFmLTg1MzMtMGU5MzliNGI0NTM1IiwiaWF0IjoxNzY5OTIxMjgwfQ.U1i3dXDGJyYIVATii86rKXgbcK2OyRmt3aHa5CyA5k8",
"N8N_API_KEY": "${N8N_API_KEY}",
"HTTP_PROXY": "",
"HTTPS_PROXY": "",
"http_proxy": "",
+51
View File
@@ -0,0 +1,51 @@
# Agent OS Runtime Notes
## Live Model Configuration
Set these environment variables before running the Agent OS goal APIs:
- `AGENT_OS_MODEL_ENDPOINT` (OpenAI-compatible chat completions endpoint)
- `AGENT_OS_MODEL_NAME` (default: `glm-4.7`)
- `AGENT_OS_MODEL_API_KEY`
- `AGENT_OS_MODEL_TIMEOUT_MS` (optional, default `30000`)
`/api/agent/goals` uses model planning first. If model config is missing or call fails, it falls back to heuristic planning and annotates `run.planning.warning`.
## n8n Production Workflow Mapping
`/api/admin/resources/skills` exposes `n8nProductionWorkflows` and corresponding n8n-backed skill manifests:
- Daily Trending discovery
- Topic discovery
- Multi-source signal aggregation
- Project description embedding refresh
- RAG similarity retrieval
- GitHub stars refresh
- Multi-source project ingest
## Runtime Policy Patch Activation
Policy proposals approved through:
- `POST /api/admin/policies/proposals`
- `POST /api/admin/approvals/:id/decision`
now immediately apply runtime patches for `targetType=agent` (policy overrides, command rules, path rules, enabled resource tags). This ensures approval changes affect subsequent runs.
## Live E2E
Playwright suite:
- `e2e/agent-os-live.spec.ts`
Required env:
- `E2E_ADMIN_KEY` (or `ADMIN_CONSOLE_KEY`)
- one of `AGENT_OS_MODEL_API_KEY` / `BIGMODEL_API_KEY` / `ZHIPU_API_KEY`
Run:
```bash
pnpm test:e2e
```
+137
View File
@@ -0,0 +1,137 @@
import { expect, test } from '@playwright/test'
function resolveAdminKey(): string | undefined {
return process.env.E2E_ADMIN_KEY || process.env.ADMIN_CONSOLE_KEY
}
function hasLiveModelKey(): boolean {
return Boolean(
process.env.AGENT_OS_MODEL_API_KEY || process.env.BIGMODEL_API_KEY || process.env.ZHIPU_API_KEY
)
}
test.describe('Agent OS Live E2E', () => {
test.skip(!resolveAdminKey(), 'E2E_ADMIN_KEY or ADMIN_CONSOLE_KEY is required')
test.skip(!hasLiveModelKey(), 'Live model API key is required for this suite')
test('runs model planning and policy approval loop with audit records', async ({
request,
page,
}) => {
const adminKey = resolveAdminKey()!
const tokenResponse = await request.post('/api/admin/auth/token', {
data: {
adminKey,
operator: 'playwright-e2e',
},
})
expect(tokenResponse.ok()).toBeTruthy()
const tokenPayload = await tokenResponse.json()
expect(tokenPayload.success).toBe(true)
const bearerToken = tokenPayload.token as string
const headers = {
Authorization: `Bearer ${bearerToken}`,
'Content-Type': 'application/json',
}
const firstRunResponse = await request.post('/api/agent/goals', {
headers,
data: {
goal: 'Please discover AI agent projects from trending signals and summarize with RAG retrieval.',
preferredAgentId: 'agent.discovery.scout',
},
})
expect(firstRunResponse.ok()).toBeTruthy()
const firstRun = await firstRunResponse.json()
expect(firstRun.success).toBe(true)
expect(firstRun.planning?.source).toBe('model')
expect(Array.isArray(firstRun.tasks)).toBe(true)
expect(firstRun.tasks.length).toBeGreaterThan(0)
expect(Array.isArray(firstRun.toolCalls)).toBe(true)
expect(firstRun.toolCalls.length).toBeGreaterThan(0)
const runId = firstRun.run?.id as string
expect(typeof runId).toBe('string')
const runDetailResponse = await request.get(`/api/agent/runs/${runId}`, { headers })
expect(runDetailResponse.ok()).toBeTruthy()
const runDetail = await runDetailResponse.json()
expect(runDetail.success).toBe(true)
expect(runDetail.run?.id).toBe(runId)
expect(runDetail.tasks.length).toBeGreaterThan(0)
const proposalResponse = await request.post('/api/admin/policies/proposals', {
headers,
data: {
title: 'Deny MCP browser for discovery scout',
description: 'Policy proposal created by live e2e to validate approval effectiveness.',
targetType: 'agent',
targetId: 'agent.discovery.scout',
changes: {
policyOverrides: {
'mcp.agent_browser.read': 'deny',
},
},
},
})
expect(proposalResponse.ok()).toBeTruthy()
const proposalPayload = await proposalResponse.json()
expect(proposalPayload.success).toBe(true)
const approvalId = proposalPayload.approvalTicket?.id as string
expect(typeof approvalId).toBe('string')
const approveResponse = await request.post(`/api/admin/approvals/${approvalId}/decision`, {
headers,
data: {
decision: 'approved',
comment: 'approved by playwright e2e',
},
})
expect(approveResponse.ok()).toBeTruthy()
const approvePayload = await approveResponse.json()
expect(approvePayload.success).toBe(true)
expect(approvePayload.approvalTicket?.status).toBe('approved')
const secondRunResponse = await request.post('/api/agent/goals', {
headers,
data: {
goal: 'Use web_read capability to read website https://example.com and extract key points.',
preferredAgentId: 'agent.discovery.scout',
},
})
expect(secondRunResponse.ok()).toBeTruthy()
const secondRun = await secondRunResponse.json()
expect(secondRun.success).toBe(true)
const webReadCalls = secondRun.toolCalls.filter(
(call: { resourceId: string }) =>
call.resourceId === 'mcp.agent_browser.read' ||
call.resourceId === 'tool.web.read' ||
call.resourceId === 'tool.web.search'
)
expect(webReadCalls.length).toBeGreaterThan(0)
expect(webReadCalls.some((call: { resourceId: string }) => call.resourceId === 'tool.web.read')).toBe(
true
)
const allReasons: string[] = secondRun.toolCalls.flatMap((call: { reasons?: string[] }) =>
Array.isArray(call.reasons) ? call.reasons : []
)
expect(allReasons.some((reason) => reason.startsWith('fallback_low_trust:'))).toBe(true)
const secondRunId = secondRun.run?.id as string
const logResponse = await request.get(`/api/admin/logs/tool-calls?runId=${secondRunId}`, {
headers,
})
expect(logResponse.ok()).toBeTruthy()
const logPayload = await logResponse.json()
expect(logPayload.success).toBe(true)
expect(logPayload.toolCalls.length).toBeGreaterThan(0)
await page.goto('/en/admin/chat')
await expect(page.getByText('Agent Gateway Chat Console')).toBeVisible()
})
})
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig, devices } from '@playwright/test'
const PORT = Number(process.env.PLAYWRIGHT_PORT || 3100)
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`
export default defineConfig({
testDir: './e2e',
timeout: 120_000,
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: [['list']],
use: {
baseURL: BASE_URL,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: `pnpm dev --port ${PORT}`,
url: BASE_URL,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
})
+5
View File
@@ -0,0 +1,5 @@
import { AdminGoalConsole } from '@/components/admin/AdminGoalConsole'
export default function AdminChatPage() {
return <AdminGoalConsole />
}
+44
View File
@@ -0,0 +1,44 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
export default async function AdminLayout({
children,
params,
}: {
children: React.ReactNode
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations('admin')
const navItems = [
{ href: `/${locale}/admin/chat`, label: t('navChat') },
{ href: `/${locale}/admin/resources`, label: t('navResources') },
{ href: `/${locale}/admin/policies/proposals`, label: t('navPolicies') },
{ href: `/${locale}/admin/runs`, label: t('navRuns') },
{ href: `/${locale}/admin/logs`, label: t('navLogs') },
]
return (
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-10 space-y-6">
<section className="neo-card p-6 bg-primary text-black">
<h1 className="font-display text-3xl font-bold">{t('title')}</h1>
<p className="text-sm mt-2 max-w-4xl">{t('subtitle')}</p>
</section>
<nav className="neo-card p-4 flex flex-wrap gap-3">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className="neo-btn bg-white dark:bg-surface-dark px-3 py-2 text-xs"
>
{item.label}
</Link>
))}
</nav>
{children}
</main>
)
}
+45
View File
@@ -0,0 +1,45 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
export default async function AdminHomePage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations('admin')
const cards = [
{
href: `/${locale}/admin/chat`,
title: t('cardChatTitle'),
description: t('cardChatDescription'),
},
{
href: `/${locale}/admin/resources`,
title: t('cardResourceTitle'),
description: t('cardResourceDescription'),
},
{
href: `/${locale}/admin/policies/proposals`,
title: t('cardPolicyTitle'),
description: t('cardPolicyDescription'),
},
{
href: `/${locale}/admin/logs`,
title: t('cardLogsTitle'),
description: t('cardLogsDescription'),
},
]
return (
<section className="grid gap-4 md:grid-cols-2">
{cards.map((card) => (
<Link key={card.href} href={card.href} className="neo-card p-5 hover:translate-x-[2px] hover:translate-y-[2px] transition-transform">
<h2 className="font-display text-xl font-bold mb-2">{card.title}</h2>
<p className="text-sm text-gray-700 dark:text-gray-300">{card.description}</p>
</Link>
))}
</section>
)
}
@@ -0,0 +1,23 @@
import { AdminPolicyProposalForm } from '@/components/admin/AdminPolicyProposalForm'
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminPolicyProposalsPage() {
const t = await getTranslations('admin')
return (
<div className="space-y-4">
<AdminPolicyProposalForm />
<AdminJsonPanel
title={t('policyProposalsTitle')}
endpoint="/api/admin/policies/proposals"
description={t('policyProposalsDescription')}
/>
<AdminJsonPanel
title={t('approvalTicketsTitle')}
endpoint="/api/admin/approvals"
description={t('approvalTicketsDescription')}
/>
</div>
)
}
@@ -0,0 +1,14 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminAgentsPage() {
const t = await getTranslations('admin')
return (
<AdminJsonPanel
title={t('agentsTitle')}
endpoint="/api/admin/resources/agents"
description={t('agentsDescription')}
/>
)
}
@@ -0,0 +1,14 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminMcpPage() {
const t = await getTranslations('admin')
return (
<AdminJsonPanel
title={t('mcpTitle')}
endpoint="/api/admin/resources/mcp"
description={t('mcpDescription')}
/>
)
}
+37
View File
@@ -0,0 +1,37 @@
import Link from 'next/link'
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminResourcesPage({
params,
}: {
params: Promise<{ locale: string }>
}) {
const { locale } = await params
const t = await getTranslations('admin')
const links = [
{ href: `/${locale}/admin/resources/tools`, label: t('resourcesTabTools') },
{ href: `/${locale}/admin/resources/mcp`, label: 'MCP' },
{ href: `/${locale}/admin/resources/skills`, label: t('resourcesTabSkills') },
{ href: `/${locale}/admin/resources/agents`, label: t('resourcesTabAgents') },
]
return (
<div className="space-y-4">
<section className="neo-card p-4 flex flex-wrap gap-3">
{links.map((link) => (
<Link key={link.href} href={link.href} className="neo-btn bg-white dark:bg-surface-dark px-3 py-2 text-xs">
{link.label}
</Link>
))}
</section>
<AdminJsonPanel
title={t('resourcesOverviewTitle')}
endpoint="/api/admin/resources"
description={t('resourcesOverviewDescription')}
/>
</div>
)
}
@@ -0,0 +1,14 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminSkillsPage() {
const t = await getTranslations('admin')
return (
<AdminJsonPanel
title={t('skillsTitle')}
endpoint="/api/admin/resources/skills"
description={t('skillsDescription')}
/>
)
}
@@ -0,0 +1,14 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminToolsPage() {
const t = await getTranslations('admin')
return (
<AdminJsonPanel
title={t('toolsTitle')}
endpoint="/api/admin/resources/tools"
description={t('toolsDescription')}
/>
)
}
+19
View File
@@ -0,0 +1,19 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminRunDetailPage({
params,
}: {
params: Promise<{ locale: string; id: string }>
}) {
const { id } = await params
const t = await getTranslations('admin')
return (
<AdminJsonPanel
title={t('runDetailTitle', { id })}
endpoint={`/api/agent/runs/${id}`}
description={t('runDetailDescription')}
/>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { AdminJsonPanel } from '@/components/admin/AdminJsonPanel'
import { getTranslations } from 'next-intl/server'
export default async function AdminRunsPage() {
const t = await getTranslations('admin')
return (
<div className="space-y-4">
<AdminJsonPanel
title={t('runsListTitle')}
endpoint="/api/admin/runs"
description={t('runsListDescription')}
/>
<AdminJsonPanel
title={t('runsControlTitle')}
endpoint="/api/agent/runs/REPLACE_RUN_ID/control"
method="POST"
bodyTemplate={JSON.stringify(
{
action: 'pause',
note: t('runsControlDefaultNote'),
},
null,
2
)}
description={t('runsControlDescription')}
/>
</div>
)
}
+26 -8
View File
@@ -1,5 +1,6 @@
import { notFound } from "next/navigation"
import { setRequestLocale, getTranslations } from 'next-intl/server'
import { NextIntlClientProvider } from 'next-intl'
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server'
import Link from "next/link"
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
@@ -43,11 +44,19 @@ export default async function LocaleLayout({
const t = await getTranslations('layout')
const tNav = await getTranslations('navigation')
const tHome = await getTranslations('home')
const messages = await getMessages()
const showAdminTab = process.env.NODE_ENV === 'development'
return (
<div className="min-h-screen flex flex-col">
{/* Top announcement bar */}
<AnnouncementBar text={t('announcement')} href={t('announcementHref')} locale={locale} />
<NextIntlClientProvider messages={messages}>
<div className="min-h-screen flex flex-col">
{/* Top announcement bar */}
<AnnouncementBar
text={t('announcement')}
href={t('announcementHref')}
locale={locale}
closeLabel={t('closeAnnouncement')}
/>
{/* Navigation */}
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
@@ -87,6 +96,14 @@ export default async function LocaleLayout({
>
{tNav('about')}
</Link>
{showAdminTab ? (
<Link
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
href={`/${locale}/admin`}
>
{tNav('admin')}
</Link>
) : null}
</nav>
{/* Right side */}
@@ -175,9 +192,9 @@ export default async function LocaleLayout({
<div>
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">Newsletter</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">Documentation</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceNewsletter')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceUpdates')}</Link></li>
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceDocumentation')}</Link></li>
</ul>
</div>
<div>
@@ -204,6 +221,7 @@ export default async function LocaleLayout({
</div>
</div>
</footer>
</div>
</div>
</NextIntlClientProvider>
)
}
@@ -331,7 +331,8 @@ export function ProjectsResultsClient({
})
if (!response.ok) {
throw new Error(response.statusText || 'Projects fetch failed')
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
throw new Error(`${projectsFetchFailedText} (${response.status})`)
}
const data = await response.json()
@@ -369,12 +370,13 @@ export function ProjectsResultsClient({
setTraditionalLimit(nextLimit)
replaceProjectsUrl(safePage, nextSort, false, nextLimit)
} catch (error) {
setTraditionalError(error instanceof Error ? error.message : 'Projects fetch failed')
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
setTraditionalError(error instanceof Error ? error.message : projectsFetchFailedText)
} finally {
setLoadingTraditional(false)
}
},
[projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
[locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
)
const handleTraditionalPageChange = useCallback(
@@ -431,7 +433,8 @@ export function ProjectsResultsClient({
})
if (!response.ok) {
throw new Error(response.statusText || 'AI search failed')
const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
throw new Error(`${aiSearchFailedText} (${response.status})`)
}
const data = await response.json()
@@ -477,7 +480,8 @@ export function ProjectsResultsClient({
}
} catch (error) {
if (!cancelled) {
setAiError(error instanceof Error ? error.message : 'AI search failed')
const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
setAiError(error instanceof Error ? error.message : aiSearchFailedText)
setAiResults([])
setAiPagination((prev) => ({
...prev,
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import { resolveAdminOperator } from '@/lib/agent-os/admin-auth'
export function parseBearerToken(authHeader: string | null): string | null {
if (!authHeader) {
return null
}
const [type, token] = authHeader.split(' ')
if (!type || !token) {
return null
}
return type.toLowerCase() === 'bearer' ? token : null
}
export function requireAdminAccess(request: Request):
| { authorized: true; operator: string }
| { authorized: false; response: NextResponse } {
const bearerToken = parseBearerToken(request.headers.get('authorization'))
const adminKey = request.headers.get('x-admin-key')
const operatorHint = request.headers.get('x-operator') || request.headers.get('x-client-id')
const resolved = resolveAdminOperator({
bearerToken,
adminKey,
fallbackOperator: operatorHint,
})
if (!resolved.authorized || !resolved.operator) {
return {
authorized: false,
response: NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Missing or invalid admin credentials'],
},
{ status: 401 }
),
}
}
return {
authorized: true,
operator: resolved.operator,
}
}
@@ -0,0 +1,64 @@
import { NextResponse } from 'next/server'
import { ApprovalDecisionRequestSchema } from '@/lib/agent-os/schemas'
import { decideApproval } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../../../_lib/auth'
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const { id } = await params
try {
const body = await request.json()
const validation = ApprovalDecisionRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 400 }
)
}
const updated = decideApproval({
approvalId: id,
decision: validation.data.decision,
decidedBy: auth.operator,
comment: validation.data.comment,
})
if (!updated) {
return NextResponse.json(
{
success: false,
error: 'Approval ticket not found',
},
{ status: 404 }
)
}
return NextResponse.json({
success: true,
approvalTicket: updated.approvalTicket,
proposal: updated.proposal,
})
} catch (error) {
console.error('[AdminApprovals] Failed to decide approval:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextResponse } from 'next/server'
import { listApprovalTickets, listPolicyProposals } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const proposals = listPolicyProposals()
const proposalById = new Map(proposals.map((proposal) => [proposal.id, proposal]))
const approvals = listApprovalTickets().map((ticket) => {
const proposal = proposalById.get(ticket.proposalId)
return {
approvalTicketId: ticket.id,
proposalId: ticket.proposalId,
approvalStatus: ticket.status,
requestedBy: ticket.requestedBy,
decidedBy: ticket.decidedBy || null,
decisionComment: ticket.decisionComment || null,
createdAt: ticket.createdAt,
updatedAt: ticket.updatedAt,
proposal: proposal
? {
title: proposal.title,
status: proposal.status,
targetType: proposal.targetType,
targetId: proposal.targetId,
}
: null,
}
})
return NextResponse.json({
success: true,
operator: auth.operator,
approvals,
})
}
+52
View File
@@ -0,0 +1,52 @@
import { NextResponse } from 'next/server'
import { AdminAuthTokenRequestSchema } from '@/lib/agent-os/schemas'
import { issueAdminToken, isValidAdminKey } from '@/lib/agent-os/admin-auth'
export async function POST(request: Request) {
try {
const body = await request.json()
const validation = AdminAuthTokenRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 400 }
)
}
const { adminKey, operator } = validation.data
if (!isValidAdminKey(adminKey)) {
return NextResponse.json(
{
success: false,
error: 'Unauthorized',
details: ['Invalid admin key'],
},
{ status: 401 }
)
}
const token = issueAdminToken(operator || 'admin-console')
return NextResponse.json({
success: true,
token: token.token,
operator: token.operator,
expiresAt: new Date(token.expiresAt).toISOString(),
})
} catch (error) {
console.error('[AdminAuth] Failed to issue token:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
@@ -0,0 +1,60 @@
import { NextResponse } from 'next/server'
import { CreatePolicyProposalRequestSchema } from '@/lib/agent-os/schemas'
import { createPolicyProposal, listPolicyProposals } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
return NextResponse.json({
success: true,
operator: auth.operator,
proposals: listPolicyProposals(),
})
}
export async function POST(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
try {
const body = await request.json()
const validation = CreatePolicyProposalRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 400 }
)
}
const created = createPolicyProposal({
...validation.data,
createdBy: auth.operator,
})
return NextResponse.json({
success: true,
proposal: created.proposal,
approvalTicket: created.approvalTicket,
})
} catch (error) {
console.error('[AdminPolicies] Failed to create proposal:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { listAgentProfiles } from '@/lib/agent-os/resource-pool'
import { requireAdminAccess } from '../../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
return NextResponse.json({
success: true,
operator: auth.operator,
agents: listAgentProfiles(),
})
}
+18
View File
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server'
import { listResourceManifests } from '@/lib/agent-os/resource-pool'
import { requireAdminAccess } from '../../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const mcpResources = listResourceManifests().filter((resource) => resource.kind === 'mcp_tool')
return NextResponse.json({
success: true,
operator: auth.operator,
mcpResources,
})
}
+33
View File
@@ -0,0 +1,33 @@
import { NextResponse } from 'next/server'
import {
listAgentProfiles,
listN8nProductionWorkflows,
listPolicyProfiles,
listResourceManifests,
} from '@/lib/agent-os/resource-pool'
import { requireAdminAccess } from '../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const resources = listResourceManifests()
const tools = resources.filter((item) => item.kind === 'base_tool' || item.kind === 'mcp_tool')
return NextResponse.json({
success: true,
operator: auth.operator,
summary: {
totalResources: resources.length,
totalTools: tools.length,
totalSkills: resources.filter((item) => item.kind === 'skill').length,
totalAgents: listAgentProfiles().length,
totalPolicyProfiles: listPolicyProfiles().length,
totalN8nProductionWorkflows: listN8nProductionWorkflows().length,
},
resources,
n8nProductionWorkflows: listN8nProductionWorkflows(),
})
}
@@ -0,0 +1,22 @@
import { NextResponse } from 'next/server'
import {
listN8nProductionWorkflows,
listResourceManifests,
} from '@/lib/agent-os/resource-pool'
import { requireAdminAccess } from '../../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const skills = listResourceManifests().filter((resource) => resource.kind === 'skill')
return NextResponse.json({
success: true,
operator: auth.operator,
skills,
n8nProductionWorkflows: listN8nProductionWorkflows(),
})
}
@@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { listResourceManifests } from '@/lib/agent-os/resource-pool'
import { requireAdminAccess } from '../../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const tools = listResourceManifests().filter(
(resource) => resource.kind === 'base_tool' || resource.kind === 'mcp_tool'
)
return NextResponse.json({
success: true,
operator: auth.operator,
tools,
})
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import { listRuns } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../_lib/auth'
export async function GET(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
return NextResponse.json({
success: true,
operator: auth.operator,
runs: listRuns(),
})
}
+56
View File
@@ -0,0 +1,56 @@
import { NextResponse } from 'next/server'
import { AgentGoalRequestSchema } from '@/lib/agent-os/schemas'
import { orchestrateGoal } from '@/lib/agent-os/orchestrator'
import { getRun, listTasks, listToolCalls } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../../admin/_lib/auth'
export async function POST(request: Request) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
try {
const body = await request.json()
const validation = AgentGoalRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 400 }
)
}
const orchestration = await orchestrateGoal({
...validation.data,
createdBy: auth.operator,
})
const run = getRun(orchestration.runId)
return NextResponse.json({
success: true,
run,
intents: orchestration.intents,
selectedAgent: orchestration.agent,
tasks: listTasks(orchestration.runId),
toolCalls: listToolCalls(orchestration.runId),
pendingApprovals: orchestration.pendingApprovals,
planning: orchestration.planning,
approvalTicketIds: orchestration.approvalTicketIds,
})
} catch (error) {
console.error('[AgentGoals] Failed to orchestrate goal:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
@@ -0,0 +1,71 @@
import { NextResponse } from 'next/server'
import { RunControlRequestSchema } from '@/lib/agent-os/schemas'
import { getRun, updateRunStatus } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../../../../admin/_lib/auth'
const ACTION_TO_STATUS = {
pause: 'PAUSED',
resume: 'TASK_DISPATCHED',
rollback: 'ROLLED_BACK',
fail: 'FAILED',
complete: 'COMPLETED',
} as const
export async function POST(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const { id } = await params
if (!getRun(id)) {
return NextResponse.json(
{
success: false,
error: 'Run not found',
},
{ status: 404 }
)
}
try {
const body = await request.json()
const validation = RunControlRequestSchema.safeParse(body)
if (!validation.success) {
return NextResponse.json(
{
success: false,
error: 'Validation error',
details: validation.error.errors,
},
{ status: 400 }
)
}
const next = updateRunStatus(
id,
ACTION_TO_STATUS[validation.data.action],
validation.data.note || `Run action: ${validation.data.action}`
)
return NextResponse.json({
success: true,
operator: auth.operator,
run: next,
})
} catch (error) {
console.error('[AgentRuns] Failed to control run:', error)
return NextResponse.json(
{
success: false,
error: 'Internal server error',
},
{ status: 500 }
)
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server'
import { getRun, listTasks, listToolCalls } from '@/lib/agent-os/store'
import { requireAdminAccess } from '../../../admin/_lib/auth'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const auth = requireAdminAccess(request)
if (!auth.authorized) {
return auth.response
}
const { id } = await params
const run = getRun(id)
if (!run) {
return NextResponse.json(
{
success: false,
error: 'Run not found',
},
{ status: 404 }
)
}
return NextResponse.json({
success: true,
operator: auth.operator,
run,
tasks: listTasks(id),
toolCalls: listToolCalls(id),
})
}
+91
View File
@@ -0,0 +1,91 @@
'use client'
import { FormEvent, useState } from 'react'
import { useTranslations } from 'next-intl'
export function AdminGoalConsole() {
const t = useTranslations('admin')
const [adminKey, setAdminKey] = useState('')
const [goal, setGoal] = useState(() => t('goalDefault'))
const [loading, setLoading] = useState(false)
const [responseText, setResponseText] = useState('')
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
setResponseText('')
try {
const response = await fetch('/api/agent/goals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': adminKey,
'x-operator': 'admin-console-chat',
},
body: JSON.stringify({ goal }),
})
const json = await response.json()
setResponseText(JSON.stringify(json, null, 2))
} catch (error) {
setResponseText(
JSON.stringify(
{
success: false,
error: error instanceof Error ? error.message : t('unknownError'),
},
null,
2
)
)
} finally {
setLoading(false)
}
}
return (
<section className="neo-card p-6 space-y-4">
<h2 className="font-display text-2xl font-bold">{t('goalConsoleTitle')}</h2>
<p className="text-sm text-gray-600 dark:text-gray-300">
{t('goalConsoleDescription')}
</p>
<form onSubmit={handleSubmit} className="space-y-3">
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
<input
type="password"
value={adminKey}
onChange={(event) => setAdminKey(event.target.value)}
className="neo-input w-full p-2 text-sm"
placeholder={t('adminKeyPlaceholder')}
required
/>
</label>
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('goalLabel')}</span>
<textarea
value={goal}
onChange={(event) => setGoal(event.target.value)}
className="neo-input w-full p-2 h-28 text-sm"
required
/>
</label>
<button
type="submit"
className="neo-btn bg-primary text-black px-4 py-2 text-sm disabled:opacity-60"
disabled={!adminKey || !goal || loading}
>
{loading ? t('submitting') : t('submitGoal')}
</button>
</form>
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{responseText || t('noRunYet')}
</pre>
</section>
)
}
+104
View File
@@ -0,0 +1,104 @@
'use client'
import { useState } from 'react'
import { useTranslations } from 'next-intl'
type AdminJsonPanelProps = {
title: string
endpoint: string
method?: 'GET' | 'POST'
bodyTemplate?: string
description?: string
}
export function AdminJsonPanel({
title,
endpoint,
method = 'GET',
bodyTemplate,
description,
}: AdminJsonPanelProps) {
const t = useTranslations('admin')
const [adminKey, setAdminKey] = useState('')
const [body, setBody] = useState(bodyTemplate || '')
const [loading, setLoading] = useState(false)
const [responseText, setResponseText] = useState('')
async function handleFetch() {
setLoading(true)
setResponseText('')
try {
const parsedBody = body ? JSON.parse(body) : undefined
const response = await fetch(endpoint, {
method,
headers: {
'Content-Type': 'application/json',
'x-admin-key': adminKey,
'x-operator': 'admin-console-ui',
},
body: method === 'POST' ? JSON.stringify(parsedBody || {}) : undefined,
})
const json = await response.json()
setResponseText(JSON.stringify(json, null, 2))
} catch (error) {
setResponseText(
JSON.stringify(
{
success: false,
error: error instanceof Error ? error.message : t('unknownError'),
},
null,
2
)
)
} finally {
setLoading(false)
}
}
return (
<section className="neo-card p-6 space-y-4">
<div>
<h3 className="font-display text-xl font-bold">{title}</h3>
{description ? <p className="text-sm text-gray-600 dark:text-gray-300 mt-1">{description}</p> : null}
</div>
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
<input
type="password"
value={adminKey}
onChange={(event) => setAdminKey(event.target.value)}
className="neo-input w-full p-2 text-sm"
placeholder={t('adminKeyPlaceholder')}
/>
</label>
{method === 'POST' ? (
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('requestJsonLabel')}</span>
<textarea
value={body}
onChange={(event) => setBody(event.target.value)}
className="neo-input w-full p-2 h-40 font-mono text-xs"
/>
</label>
) : null}
<button
type="button"
onClick={handleFetch}
disabled={!adminKey || loading}
className="neo-btn bg-primary text-black px-4 py-2 text-sm disabled:opacity-60"
>
{loading ? t('loading') : t('executeEndpoint', { method, endpoint })}
</button>
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{responseText || t('noResponseYet')}
</pre>
</section>
)
}
@@ -0,0 +1,148 @@
'use client'
import { FormEvent, useState } from 'react'
import { useTranslations } from 'next-intl'
export function AdminPolicyProposalForm() {
const t = useTranslations('admin')
const initialPayload = {
title: t('policyDefaultTitle'),
description: t('policyDefaultDescription'),
targetType: 'agent',
targetId: 'agent.orchestrator.core',
changes: {
commandRules: [
{
resourceId: 'tool.shell.exec',
pattern: 'git push *',
effect: 'deny',
},
],
},
}
const [adminKey, setAdminKey] = useState('')
const [payload, setPayload] = useState(JSON.stringify(initialPayload, null, 2))
const [approvalId, setApprovalId] = useState('')
const [decisionComment, setDecisionComment] = useState('')
const [responseText, setResponseText] = useState('')
async function handleProposalSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
try {
const parsed = JSON.parse(payload)
const response = await fetch('/api/admin/policies/proposals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': adminKey,
'x-operator': 'policy-admin',
},
body: JSON.stringify(parsed),
})
const json = await response.json()
setResponseText(JSON.stringify(json, null, 2))
} catch (error) {
setResponseText(
JSON.stringify(
{
success: false,
error: error instanceof Error ? error.message : t('unknownError'),
},
null,
2
)
)
}
}
async function handleDecision(decision: 'approved' | 'rejected') {
if (!approvalId) {
return
}
const response = await fetch(`/api/admin/approvals/${approvalId}/decision`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-admin-key': adminKey,
'x-operator': 'policy-approver',
},
body: JSON.stringify({ decision, comment: decisionComment }),
})
const json = await response.json()
setResponseText(JSON.stringify(json, null, 2))
}
return (
<section className="neo-card p-6 space-y-4">
<h2 className="font-display text-2xl font-bold">{t('policyTitle')}</h2>
<form className="space-y-3" onSubmit={handleProposalSubmit}>
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('adminKeyLabel')}</span>
<input
type="password"
className="neo-input w-full p-2 text-sm"
value={adminKey}
onChange={(event) => setAdminKey(event.target.value)}
placeholder={t('adminKeyPlaceholder')}
required
/>
</label>
<label className="block">
<span className="font-display text-xs uppercase font-bold mb-1 block">{t('proposalJsonLabel')}</span>
<textarea
className="neo-input w-full p-2 h-56 text-xs font-mono"
value={payload}
onChange={(event) => setPayload(event.target.value)}
/>
</label>
<button type="submit" className="neo-btn bg-primary text-black px-4 py-2 text-sm">
{t('submitProposal')}
</button>
</form>
<div className="border-t-2 border-black dark:border-gray-600 pt-4 space-y-2">
<h3 className="font-display text-lg font-bold">{t('approvalDecisionTitle')}</h3>
<input
className="neo-input w-full p-2 text-sm"
placeholder={t('approvalTicketIdPlaceholder')}
value={approvalId}
onChange={(event) => setApprovalId(event.target.value)}
/>
<input
className="neo-input w-full p-2 text-sm"
placeholder={t('decisionCommentPlaceholder')}
value={decisionComment}
onChange={(event) => setDecisionComment(event.target.value)}
/>
<div className="flex gap-3">
<button
type="button"
className="neo-btn bg-green-300 text-black px-4 py-2 text-sm"
onClick={() => handleDecision('approved')}
disabled={!adminKey || !approvalId}
>
{t('approve')}
</button>
<button
type="button"
className="neo-btn bg-red-300 text-black px-4 py-2 text-sm"
onClick={() => handleDecision('rejected')}
disabled={!adminKey || !approvalId}
>
{t('reject')}
</button>
</div>
</div>
<pre className="neo-card p-3 bg-black text-green-300 text-xs overflow-auto max-h-96 whitespace-pre-wrap">
{responseText || t('noResponseYet')}
</pre>
</section>
)
}
+3 -2
View File
@@ -7,9 +7,10 @@ interface AnnouncementBarProps {
text: string
href: string
locale: string
closeLabel: string
}
export function AnnouncementBar({ text, href, locale }: AnnouncementBarProps) {
export function AnnouncementBar({ text, href, locale, closeLabel }: AnnouncementBarProps) {
const [isVisible, setIsVisible] = useState(true)
useEffect(() => {
@@ -43,7 +44,7 @@ export function AnnouncementBar({ text, href, locale }: AnnouncementBarProps) {
<button
onClick={handleClose}
className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors"
aria-label="Close announcement"
aria-label={closeLabel}
>
<span className="material-icons text-sm">close</span>
</button>
+27 -6
View File
@@ -13,9 +13,18 @@ export interface GitHubTextStats {
license?: string | null // 文本值(如 "MIT")或徽章 URL
}
interface GitHubTextStatsLabels {
header: string
starsTitle: string
forksTitle: string
issuesTitle: string
licenseTitle: string
}
interface GitHubTextStatsCardProps {
stats: GitHubTextStats
className?: string
labels?: GitHubTextStatsLabels
}
/**
@@ -50,7 +59,19 @@ function renderStatValue(value: string) {
*
* 设计原型来源: design/detail.html 第 223-289 行
*/
export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCardProps) {
const DEFAULT_LABELS: GitHubTextStatsLabels = {
header: 'GitHub Statistics',
starsTitle: 'View stars on GitHub',
forksTitle: 'View forks on GitHub',
issuesTitle: 'View issues on GitHub',
licenseTitle: 'View license on GitHub',
}
export function GitHubTextStatsCard({
stats,
className = '',
labels = DEFAULT_LABELS,
}: GitHubTextStatsCardProps) {
// 如果没有任何统计数据,不显示卡片
if (!stats.stars && !stats.forks && !stats.issues && !stats.license) {
return null
@@ -63,7 +84,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
<svg className="w-4 h-4 text-primary" fill="currentColor" viewBox="0 0 16 16">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-3.14-.95-3.14-.95-.43-.92-.1-1.25-.1-1.25.2-.05.41.08.95.08 2.76 1.89 3.78 1.89 3.78 1.69 2.88 4.44 2.05 5.53-.16.47-.86-.94-1.25-1.14-.42-.26-.89-.04-1.25.23-.89.65-2.18.95-3.3 1.01-.22.01-.44.05-.66.12-.26.11-.53.03-.72-.13-.22-.18-.44-.49-.44-.49-.25-.95-.08-1.25.23-.72.72-1.87 2.05-2.84 2.85-.13.11-.24.26-.24.42 0 .33.27.76.76.76 1.16 0 .87.72 1.96 2.05 2.4 2.82.2.06.43.09.65.05.31-.06.6-.17.87-.33.26-.16.48-.36.63-.57.23-.21.47-.44.64-.67.19-.26.35-.54.48-.83.11-.26.17-.54.17-.82 0-.47-.27-.91-.66-1.1-.67-.34-1.45-.51-2.32-.51-.88 0-1.67.18-2.35.53-.26.13-.5.25-.73.36-.22.1-.42.23-.58.37-.14.13-.26.26-.34.4-.07.14-.1.29-.1.44 0 .18.09.34.25.46.13.11.29.18.46.18.19 0 .38-.06.55-.17.15-.11.28-.24.39-.39.1-.15.18-.31.23-.48.05-.18.06-.36.06-.54 0-.3-.17-.57-.44-.74-.23-.17-.5-.26-.78-.26-.28 0-.55.09-.79.26-.23.17-.41.39-.54.64-.12.24-.17.5-.17.77 0 .26.17.5.43.67.25.17.57.26.89.26.31 0 .6-.09.85-.26.23-.17.41-.39.54-.64.12-.24.18-.5.18-.77 0-.26-.17-.5-.43-.67-.26-.17-.57-.26-.89-.26-.31 0-.6.09-.85.26-.23.17-.41.39-.54-.64-.12.24-.17.5-.17.77zM8 15c-3.86 0-7-3.14-7-7s3.14-7 7-7 7 3.14 7 7-3.14 7-7 7z"/>
</svg>
GitHub Statistics
{labels.header}
</h3>
{/* Stats Grid - 2 columns */}
@@ -73,7 +94,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
<Link
href={`https://github.com/${stats.owner}/${stats.repo}/stargazers`}
className="group"
title="View stars on GitHub"
title={labels.starsTitle}
>
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
<svg className="w-4 h-4 text-primary flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
@@ -89,7 +110,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
<Link
href={`https://github.com/${stats.owner}/${stats.repo}/network/members`}
className="group"
title="View forks on GitHub"
title={labels.forksTitle}
>
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
@@ -105,7 +126,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
<Link
href={`https://github.com/${stats.owner}/${stats.repo}/issues`}
className="group"
title="View issues on GitHub"
title={labels.issuesTitle}
>
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
<svg className="w-4 h-4 text-green-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
@@ -122,7 +143,7 @@ export function GitHubTextStatsCard({ stats, className = '' }: GitHubTextStatsCa
<Link
href={`https://github.com/${stats.owner}/${stats.repo}/blob/main/LICENSE`}
className="group"
title="View license on GitHub"
title={labels.licenseTitle}
>
<div className="flex items-center gap-2 hover:bg-white dark:hover:bg-black hover:bg-opacity-50 p-2 rounded transition-colors">
<svg className="w-4 h-4 text-purple-600 flex-shrink-0" fill="currentColor" viewBox="0 0 16 16">
+3 -2
View File
@@ -7,6 +7,7 @@ import type { Components } from 'react-markdown'
interface MarkdownContentProps {
content: string
className?: string
noContentText?: string
}
const BLOCK_LEVEL_TAGS = new Set([
@@ -258,11 +259,11 @@ const components: Components = {
) : null,
}
export function MarkdownContent({ content, className = '' }: MarkdownContentProps) {
export function MarkdownContent({ content, className = '', noContentText }: MarkdownContentProps) {
if (!content || content.trim() === '') {
return (
<p className="text-gray-500 dark:text-gray-400 italic">No content available.</p>
<p className="text-gray-500 dark:text-gray-400 italic">{noContentText ?? 'No content available.'}</p>
)
}
+6 -5
View File
@@ -46,7 +46,7 @@ function getProjectIcon(tags: Array<{ name: string | null }>): string {
export function ProjectCard({ project, locale, featured = false, translations }: ProjectCardProps) {
// 使用传入的翻译文本,如果没有提供则使用默认值
const viewDetailsText = translations?.viewDetails || 'View Details'
const viewDetailsText = translations?.viewDetails || (locale === 'zh' ? '查看详情' : 'View Details')
const icon = getProjectIcon(project.tags)
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
const displayDescription = locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
@@ -109,7 +109,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
</svg>
<Image
src={badges.stars}
alt="Stars"
alt={locale === 'zh' ? 'Star' : 'Stars'}
width={70}
height={20}
unoptimized
@@ -135,9 +135,10 @@ interface SubmitProjectCardProps {
}
export function SubmitProjectCard({ locale, translations }: SubmitProjectCardProps) {
const submitProjectTitle = translations?.submitProjectTitle || 'Submit Your Project'
const submitProjectDescription = translations?.submitProjectDescription || 'Help us grow the AI ecosystem'
const submitNow = translations?.submitNow || 'Submit Now'
const submitProjectTitle = translations?.submitProjectTitle || (locale === 'zh' ? '提交你的项目' : 'Submit Your Project')
const submitProjectDescription =
translations?.submitProjectDescription || (locale === 'zh' ? '帮助我们一起完善 AI 生态' : 'Help us grow the AI ecosystem')
const submitNow = translations?.submitNow || (locale === 'zh' ? '立即提交' : 'Submit Now')
return (
<article className="bg-primary border-2 border-black dark:border-gray-600 p-6 shadow-neo hover:shadow-neo-hover hover:-translate-y-1 transition-all duration-200 flex flex-col h-full justify-center items-center text-center group">
+5 -3
View File
@@ -58,6 +58,8 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
const categoryEn =
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
const displayCategory = locale === 'en' ? categoryEn : category
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return (
<>
@@ -77,7 +79,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="material-icons text-base">calendar_today</span>
<span>Added {formatDate(project.createdAt, locale)}</span>
<span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span>
</div>
<span className="hidden sm:inline text-gray-300">|</span>
<div className="flex items-center gap-2">
@@ -98,7 +100,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
key={tag.id}
className="px-3 py-1 border border-black dark:border-gray-500 text-xs font-display font-bold uppercase bg-white dark:bg-gray-800"
>
{tag.name}
{getTagName(tag)}
</span>
))}
</div>
@@ -112,7 +114,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
</p>
{/* Full content with Markdown rendering */}
{displayContent && <MarkdownContent content={displayContent} />}
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
</article>
{/* Share and Feedback Section - Temporarily disabled for debugging */}
@@ -95,6 +95,13 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
issues: githubInfo.issues,
license: githubInfo.license
}}
labels={{
header: t('githubStatsTitle'),
starsTitle: t('githubStatsViewStars'),
forksTitle: t('githubStatsViewForks'),
issuesTitle: t('githubStatsViewIssues'),
licenseTitle: t('githubStatsViewLicense'),
}}
/>
)}
+11 -5
View File
@@ -1,4 +1,5 @@
import Link from 'next/link'
import { getTranslations } from 'next-intl/server'
interface RelatedProjectsProps {
projects: Array<{
@@ -29,7 +30,10 @@ function getProjectIcon(tags: Array<{ name: string }>): string {
return '✨'
}
export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
export async function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
const tProject = await getTranslations('project')
const tCommon = await getTranslations('common')
if (projects.length === 0) {
return null
}
@@ -37,12 +41,12 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
return (
<section className="mt-20 pt-12 border-t border-black dark:border-gray-700">
<div className="flex justify-between items-end mb-8">
<h2 className="font-display text-3xl font-bold uppercase">Related Projects</h2>
<h2 className="font-display text-3xl font-bold uppercase">{tProject('relatedProjects')}</h2>
<Link
href={`/${locale}/projects`}
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
>
View All{' '}
{tProject('viewAll')}{' '}
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span>
</Link>
</div>
@@ -52,6 +56,8 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
const displayDescription =
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
const icon = getProjectIcon(project.tags)
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
return (
<Link
@@ -72,13 +78,13 @@ export function RelatedProjects({ projects, locale }: RelatedProjectsProps) {
key={tag.id}
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
>
{tag.name}
{getTagName(tag)}
</span>
))}
</div>
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
<span className="text-xs font-display font-bold uppercase flex items-center">
View Details{' '}
{tCommon('viewDetails')}{' '}
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform">
arrow_forward
</span>
+9 -5
View File
@@ -1,13 +1,17 @@
'use client'
import { useTranslations } from 'next-intl'
interface ShareButtonsProps {
displayName: string
}
export function ShareButtons({ displayName }: ShareButtonsProps) {
const t = useTranslations('project')
const handleShare = () => {
const url = encodeURIComponent(window.location.href)
const text = encodeURIComponent(`Check out ${displayName} on Agent Park`)
const text = encodeURIComponent(t('shareTweetText', { name: displayName }))
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
}
@@ -20,7 +24,7 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
<div className="flex gap-4">
<button
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Share on X"
title={t('shareOnX')}
onClick={handleShare}
id="share-button"
name="share"
@@ -29,7 +33,7 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
</button>
<button
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
title="Copy Link"
title={t('copyLink')}
onClick={handleCopyLink}
id="copy-link-button"
name="copyLink"
@@ -38,13 +42,13 @@ export function ShareButtons({ displayName }: ShareButtonsProps) {
</button>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-display font-bold text-gray-500">DID THIS AGENT HELP YOU?</span>
<span className="text-sm font-display font-bold text-gray-500">{t('feedbackQuestion')}</span>
<button
className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
id="feedback-button"
name="feedback"
>
Yes, it rocks
{t('feedbackYes')}
</button>
</div>
</div>
+90
View File
@@ -0,0 +1,90 @@
import crypto from 'crypto'
const TOKEN_TTL_MS = 1000 * 60 * 60 * 8
type AdminTokenEntry = {
token: string
operator: string
expiresAt: number
}
declare global {
// eslint-disable-next-line no-var
var __agentParkAdminTokens: Map<string, AdminTokenEntry> | undefined
}
function getTokenStore(): Map<string, AdminTokenEntry> {
if (!globalThis.__agentParkAdminTokens) {
globalThis.__agentParkAdminTokens = new Map<string, AdminTokenEntry>()
}
return globalThis.__agentParkAdminTokens
}
function getExpectedAdminKey(): string | undefined {
return process.env.ADMIN_CONSOLE_KEY || process.env.WEBHOOK_API_KEY
}
export function isValidAdminKey(input: string | null | undefined): boolean {
const expected = getExpectedAdminKey()
if (!input || !expected) {
return false
}
const inputBuffer = Buffer.from(input)
const expectedBuffer = Buffer.from(expected)
if (inputBuffer.length !== expectedBuffer.length) {
return false
}
return crypto.timingSafeEqual(inputBuffer, expectedBuffer)
}
export function issueAdminToken(operator: string): AdminTokenEntry {
const entry: AdminTokenEntry = {
token: crypto.randomUUID(),
operator,
expiresAt: Date.now() + TOKEN_TTL_MS,
}
getTokenStore().set(entry.token, entry)
return entry
}
export function verifyAdminToken(token: string | null | undefined): AdminTokenEntry | null {
if (!token) {
return null
}
const entry = getTokenStore().get(token)
if (!entry) {
return null
}
if (entry.expiresAt <= Date.now()) {
getTokenStore().delete(token)
return null
}
return entry
}
export function resolveAdminOperator(params: {
bearerToken?: string | null
adminKey?: string | null
fallbackOperator?: string | null
}): { authorized: boolean; operator?: string } {
const bearerEntry = verifyAdminToken(params.bearerToken)
if (bearerEntry) {
return { authorized: true, operator: bearerEntry.operator }
}
if (isValidAdminKey(params.adminKey)) {
return {
authorized: true,
operator: params.fallbackOperator || 'admin-key',
}
}
return { authorized: false }
}
+212
View File
@@ -0,0 +1,212 @@
import { z } from 'zod'
const DEFAULT_ENDPOINT = 'https://open.bigmodel.cn/api/coding/paas/v4/chat/completions'
const DEFAULT_MODEL = 'glm-4.7'
const DEFAULT_TIMEOUT_MS = 120_000
const DEFAULT_MAX_TOKENS = 1200
const UsageSchema = z
.object({
prompt_tokens: z.number().int().nonnegative().optional(),
completion_tokens: z.number().int().nonnegative().optional(),
total_tokens: z.number().int().nonnegative().optional(),
})
.partial()
const OpenAiCompatResponseSchema = z.object({
choices: z
.array(
z.object({
message: z
.object({
content: z
.union([
z.string(),
z.array(
z
.object({
type: z.string().optional(),
text: z.string().optional(),
})
.passthrough()
),
])
.nullable()
.optional(),
})
.passthrough(),
})
)
.min(1),
usage: UsageSchema.optional(),
})
export type AgentModelConfig = {
endpoint: string
model: string
apiKey: string
timeoutMs: number
}
export type AgentModelJsonCallResult<T> = {
data: T
model: string
latencyMs: number
promptTokens?: number
completionTokens?: number
totalTokens?: number
}
function normalizeEndpoint(input: string): string {
const trimmed = input.trim().replace(/\.+$/, '')
if (trimmed.endsWith('/chat/completions')) {
return trimmed
}
return `${trimmed.replace(/\/$/, '')}/chat/completions`
}
function extractTextContent(content: unknown): string {
if (typeof content === 'string') {
return content
}
if (!Array.isArray(content)) {
return ''
}
return content
.map((item) => {
if (typeof item === 'string') {
return item
}
if (
typeof item === 'object' &&
item !== null &&
'text' in item &&
typeof (item as { text?: unknown }).text === 'string'
) {
return (item as { text: string }).text
}
return ''
})
.join('\n')
}
function maybeParseJsonBlock(input: string): unknown {
const trimmed = input.trim()
if (!trimmed) {
throw new Error('Model returned empty content.')
}
try {
return JSON.parse(trimmed)
} catch {
const codeBlockMatch = trimmed.match(/```json\s*([\s\S]*?)```/i)
if (codeBlockMatch && codeBlockMatch[1]) {
return JSON.parse(codeBlockMatch[1].trim())
}
const firstBrace = trimmed.indexOf('{')
const lastBrace = trimmed.lastIndexOf('}')
if (firstBrace >= 0 && lastBrace > firstBrace) {
const candidate = trimmed.slice(firstBrace, lastBrace + 1)
return JSON.parse(candidate)
}
throw new Error('Failed to parse JSON payload from model output.')
}
}
export function getAgentModelConfig(): AgentModelConfig | null {
const apiKey =
process.env.AGENT_OS_MODEL_API_KEY ||
process.env.BIGMODEL_API_KEY ||
process.env.ZHIPU_API_KEY
if (!apiKey) {
return null
}
const endpointRaw =
process.env.AGENT_OS_MODEL_ENDPOINT ||
process.env.BIGMODEL_API_ENDPOINT ||
process.env.BIGMODEL_BASE_URL ||
DEFAULT_ENDPOINT
const model =
process.env.AGENT_OS_MODEL_NAME || process.env.BIGMODEL_MODEL || process.env.MODEL_NAME || DEFAULT_MODEL
const timeoutInput = Number(process.env.AGENT_OS_MODEL_TIMEOUT_MS || DEFAULT_TIMEOUT_MS)
const timeoutMs = Number.isFinite(timeoutInput) && timeoutInput > 0 ? timeoutInput : DEFAULT_TIMEOUT_MS
return {
endpoint: normalizeEndpoint(endpointRaw),
model,
apiKey,
timeoutMs,
}
}
export async function callAgentModelForJson<T>(input: {
config: AgentModelConfig
systemPrompt: string
userPrompt: string
schema: z.ZodType<T, z.ZodTypeDef, unknown>
temperature?: number
maxTokens?: number
}): Promise<AgentModelJsonCallResult<T>> {
const start = Date.now()
const abortController = new AbortController()
const timeoutId = setTimeout(() => abortController.abort(), input.config.timeoutMs)
try {
const response = await fetch(input.config.endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${input.config.apiKey}`,
},
body: JSON.stringify({
model: input.config.model,
temperature: input.temperature ?? 0.1,
max_tokens: input.maxTokens ?? DEFAULT_MAX_TOKENS,
thinking: { type: 'disabled' },
response_format: { type: 'json_object' },
messages: [
{ role: 'system', content: input.systemPrompt },
{ role: 'user', content: input.userPrompt },
],
}),
signal: abortController.signal,
})
if (!response.ok) {
const errorBody = await response.text()
throw new Error(
`Model endpoint request failed: ${response.status} ${response.statusText}; body=${errorBody}`
)
}
const payload = OpenAiCompatResponseSchema.parse(await response.json())
const firstChoice = payload.choices[0]
if (!firstChoice) {
throw new Error('Model returned no choices.')
}
const rawText = extractTextContent(firstChoice.message.content)
const parsed = maybeParseJsonBlock(rawText)
const validated = input.schema.parse(parsed)
const latencyMs = Date.now() - start
return {
data: validated,
model: input.config.model,
latencyMs,
promptTokens: payload.usage?.prompt_tokens,
completionTokens: payload.usage?.completion_tokens,
totalTokens: payload.usage?.total_tokens,
}
} finally {
clearTimeout(timeoutId)
}
}
@@ -0,0 +1,111 @@
import type { CapabilityId, ResourceManifest } from './types'
export type N8nProductionWorkflowReference = {
workflowId: string
name: string
active: boolean
nodeCount: number
capability: CapabilityId
summary: string
}
const N8N_PRODUCTION_WORKFLOWS: N8nProductionWorkflowReference[] = [
{
workflowId: 'hughGsWismCpk7jd',
name: '每日Github Trending项目计划新增',
active: true,
nodeCount: 18,
capability: 'discovery_trending',
summary:
'Scheduled trending source scrape, AI relevance filter, dedup check, and webhook task creation.',
},
{
workflowId: 'iw9vx9ih5Lt0Mobk',
name: 'Topic项目计划新增',
active: true,
nodeCount: 20,
capability: 'discovery_topic',
summary:
'Topic candidate generation, dedup pipeline, AI chain filtering, and low-recall alert hooks.',
},
{
workflowId: 'bAxNZKGq2ApUUiw9',
name: '前沿信号聚合(多源+AI Agent过滤)',
active: true,
nodeCount: 37,
capability: 'signal_aggregation',
summary:
'Multi-source signals (HN/GitHub/arXiv/Reddit/ProductHunt/HF), AI relevance filtering, and feedback loop to discovery.',
},
{
workflowId: '1AvejnM5n-WPApU1vFt9C',
name: '项目描述向量化',
active: true,
nodeCount: 11,
capability: 'embedding_refresh',
summary: 'Batch fetch unembedded projects, call embedding API, and update vectors in DB.',
},
{
workflowId: 'F5cQ06DykBfpeyfqL-pd7',
name: 'RAG项目搜索',
active: true,
nodeCount: 6,
capability: 'rag_search',
summary: 'Webhook query to embedding model, vector similarity SQL retrieval, and normalized response.',
},
{
workflowId: 'ewx9Gs6cjrTXvwD0',
name: 'GitHub Star 每日刷新',
active: true,
nodeCount: 7,
capability: 'github_metrics_refresh',
summary: 'Daily repository stat refresh and writeback to project metrics fields.',
},
{
workflowId: '1Ig1CyVMsGJFaHOe',
name: '项目分析入库(多源)',
active: true,
nodeCount: 20,
capability: 'project_ingest',
summary:
'Task queue consume, agent-browser assisted collection, content summarization, tag classification, and ingestion completion callbacks.',
},
]
const WORKFLOW_TO_SKILL_ID: Record<string, string> = {
hughGsWismCpk7jd: 'skill.discovery.trending.daily-plan',
iw9vx9ih5Lt0Mobk: 'skill.discovery.topic.daily-plan',
bAxNZKGq2ApUUiw9: 'skill.discovery.signal.multi-source',
'1AvejnM5n-WPApU1vFt9C': 'skill.knowledge.embedding.refresh',
'F5cQ06DykBfpeyfqL-pd7': 'skill.search.rag.similarity',
ewx9Gs6cjrTXvwD0: 'skill.metrics.github-stars.refresh',
'1Ig1CyVMsGJFaHOe': 'skill.ingest.project.multi-source',
}
export function listN8nProductionWorkflowReferences(): N8nProductionWorkflowReference[] {
return N8N_PRODUCTION_WORKFLOWS.map((workflow) => ({ ...workflow }))
}
export function listN8nBackedSkillManifests(): ResourceManifest[] {
return N8N_PRODUCTION_WORKFLOWS.map((workflow) => ({
id: WORKFLOW_TO_SKILL_ID[workflow.workflowId] || `skill.n8n.${workflow.workflowId}`,
kind: 'skill',
version: '1.0.0',
status: workflow.active ? 'active' : 'disabled',
owner: 'n8n.production',
inputSchemaRef: `schema://skills/${workflow.capability}/input`,
outputSchemaRef: `schema://skills/${workflow.capability}/output`,
riskLevel: 'L1',
defaultPolicy: 'allow',
tags: ['skill', 'n8n', workflow.capability],
latencyHintMs: 500,
metadata: {
source: 'n8n.production',
workflowId: workflow.workflowId,
workflowName: workflow.name,
nodeCount: workflow.nodeCount,
summary: workflow.summary,
},
}))
}
+111
View File
@@ -0,0 +1,111 @@
import { beforeEach, describe, expect, it } from 'vitest'
import { orchestrateGoal } from './orchestrator'
import {
createPolicyProposal,
decideApproval,
getRun,
listTasks,
listToolCalls,
resetAgentOsStateForTests,
} from './store'
describe('agent-os orchestrator', () => {
beforeEach(() => {
resetAgentOsStateForTests()
})
it('creates run/task/tool-call records for a goal', async () => {
const result = await orchestrateGoal({
goal: 'Review code quality and inspect logs',
createdBy: 'test-user',
})
const run = getRun(result.runId)
const tasks = listTasks(result.runId)
const toolCalls = listToolCalls(result.runId)
expect(run).not.toBeNull()
expect(tasks.length).toBeGreaterThan(0)
expect(toolCalls.length).toBe(tasks.length)
})
it('uses preferred agent when provided', async () => {
const result = await orchestrateGoal({
goal: 'Collect GitHub discovery signals',
createdBy: 'test-user',
preferredAgentId: 'agent.discovery.scout',
})
expect(result.agent.id).toBe('agent.discovery.scout')
})
it('adds low-trust fallback reason when mcp web reader is denied by approved proposal', async () => {
const firstRun = await orchestrateGoal({
goal: 'Read this website and inspect the project details',
createdBy: 'test-user',
preferredAgentId: 'agent.discovery.scout',
})
const firstCall = listToolCalls(firstRun.runId)[0]
expect(firstCall?.resourceId).toBe('mcp.agent_browser.read')
const proposal = createPolicyProposal({
title: 'Deny agent-browser MCP for discovery scout',
description: 'Force fallback for trust-level validation.',
targetType: 'agent',
targetId: 'agent.discovery.scout',
createdBy: 'test-user',
changes: {
policyOverrides: {
'mcp.agent_browser.read': 'deny',
},
},
})
decideApproval({
approvalId: proposal.approvalTicket.id,
decision: 'approved',
decidedBy: 'test-user',
comment: 'Approved in test',
})
const secondRun = await orchestrateGoal({
goal: 'Read this website and inspect the project details',
createdBy: 'test-user',
preferredAgentId: 'agent.discovery.scout',
})
const secondCall = listToolCalls(secondRun.runId)[0]
expect(secondCall?.resourceId).toBe('tool.web.read')
expect(secondCall?.reasons.some((reason) => reason.startsWith('fallback_low_trust:'))).toBe(
true
)
})
it('falls back to heuristic planner when model env is missing', async () => {
const previousApiKey = process.env.AGENT_OS_MODEL_API_KEY
const previousBigmodelApiKey = process.env.BIGMODEL_API_KEY
const previousZhipuApiKey = process.env.ZHIPU_API_KEY
delete process.env.AGENT_OS_MODEL_API_KEY
delete process.env.BIGMODEL_API_KEY
delete process.env.ZHIPU_API_KEY
const runResult = await orchestrateGoal({
goal: 'Need rag retrieval for agent projects',
createdBy: 'test-user',
})
const run = getRun(runResult.runId)
expect(run?.planning?.source).toBe('heuristic')
expect(run?.planning?.warning).toContain('fallback')
if (previousApiKey) {
process.env.AGENT_OS_MODEL_API_KEY = previousApiKey
}
if (previousBigmodelApiKey) {
process.env.BIGMODEL_API_KEY = previousBigmodelApiKey
}
if (previousZhipuApiKey) {
process.env.ZHIPU_API_KEY = previousZhipuApiKey
}
})
})
+671
View File
@@ -0,0 +1,671 @@
import { z } from 'zod'
import { callAgentModelForJson, getAgentModelConfig } from './model-client'
import { evaluatePolicy } from './policy-engine'
import {
getAgentProfileById,
getResourceManifestById,
listAgentProfiles,
listResourceManifests,
} from './resource-pool'
import {
addMemoryEntry,
addTask,
addToolCall,
createPolicyProposal,
createRun,
setRunPendingApprovals,
updateRunStatus,
} from './store'
import type {
AgentManifest,
AgentRun,
CapabilityId,
CapabilityIntent,
PolicyEffect,
ResourceManifest,
} from './types'
import { CAPABILITY_IDS } from './types'
const CAPABILITY_ALIAS_MAP: Record<string, CapabilityId> = {
web_read: 'web_read',
read_web: 'web_read',
code_review: 'code_review',
code_check: 'code_review',
log_review: 'log_review',
log_analysis: 'log_review',
knowledge_retrieve: 'knowledge_retrieve',
retrieve: 'knowledge_retrieve',
knowledge_write: 'knowledge_write',
ingest_knowledge: 'knowledge_write',
task_spawn: 'task_spawn',
spawn_task: 'task_spawn',
discovery_trending: 'discovery_trending',
search_trends: 'discovery_trending',
discovery_topic: 'discovery_topic',
topic_discovery: 'discovery_topic',
signal_aggregation: 'signal_aggregation',
signal_filter: 'signal_aggregation',
rag_search: 'rag_search',
rag_retrieve: 'rag_search',
embedding_refresh: 'embedding_refresh',
vectorize: 'embedding_refresh',
github_metrics_refresh: 'github_metrics_refresh',
metrics_refresh: 'github_metrics_refresh',
project_ingest: 'project_ingest',
ingest_project: 'project_ingest',
}
const PlannerIntentRawSchema = z.object({
capability: z.string().optional(),
intent: z.string().optional(),
reason: z.string().optional(),
description: z.string().optional(),
context: z.record(z.unknown()).optional(),
parameters: z.record(z.unknown()).optional(),
})
function toCapabilityId(value: string | undefined): CapabilityId | null {
if (!value) {
return null
}
const normalized = normalizeText(value).replace(/\s+/g, '_')
return CAPABILITY_ALIAS_MAP[normalized] || null
}
function normalizePlannerIntents(intents: z.infer<typeof PlannerIntentRawSchema>[]): CapabilityIntent[] {
const normalized: CapabilityIntent[] = []
intents.forEach((item) => {
const capability = toCapabilityId(item.capability || item.intent)
if (!capability) {
return
}
normalized.push({
capability,
reason: item.reason || item.description || `Planned by model for ${capability}.`,
context: item.context || item.parameters,
})
})
return normalized
}
const PlannerResponseSchema = z
.object({
intents: z.array(PlannerIntentRawSchema).min(1).max(12).optional(),
plan: z.array(PlannerIntentRawSchema).min(1).max(12).optional(),
summary: z.string().min(8).max(1200).optional(),
suggestedAgentId: z.string().max(200).optional(),
riskNotes: z.union([z.string().min(1).max(1000), z.array(z.string().min(1).max(300)).max(10)]).optional(),
answer: z.string().max(1200).optional(),
})
.superRefine((value, ctx) => {
if (!value.intents && !value.plan) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Missing intents/plan field',
})
}
})
.transform((value, ctx) => {
const normalizedIntents = normalizePlannerIntents(value.intents || value.plan || [])
if (normalizedIntents.length === 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'No valid capability intents found after normalization.',
})
return z.NEVER
}
return {
intents: normalizedIntents,
summary: value.summary || value.answer,
suggestedAgentId: value.suggestedAgentId && value.suggestedAgentId.trim().length > 0
? value.suggestedAgentId
: undefined,
riskNotes: Array.isArray(value.riskNotes)
? value.riskNotes
: value.riskNotes
? [value.riskNotes]
: [],
}
})
const INTENT_TO_RESOURCE_MAP: Record<CapabilityId, string[]> = {
web_read: ['mcp.agent_browser.read', 'tool.web.read', 'tool.web.search'],
code_review: ['tool.lsp.query', 'tool.fs.grep', 'tool.git.status_diff', 'tool.lint.run'],
log_review: ['tool.logs.query', 'tool.logs.trace'],
knowledge_retrieve: ['tool.knowledge.retrieve', 'tool.memory.read', 'tool.db.query_ro'],
knowledge_write: ['tool.knowledge.ingest_proposal', 'tool.memory.write_proposal'],
task_spawn: ['tool.task.spawn'],
discovery_trending: [
'skill.discovery.trending.daily-plan',
'mcp.agent_browser.read',
'tool.web.search',
],
discovery_topic: ['skill.discovery.topic.daily-plan', 'mcp.agent_browser.read', 'tool.web.search'],
signal_aggregation: [
'skill.discovery.signal.multi-source',
'mcp.agent_browser.read',
'tool.web.search',
],
rag_search: ['skill.search.rag.similarity', 'tool.knowledge.retrieve', 'tool.db.query_ro'],
embedding_refresh: [
'skill.knowledge.embedding.refresh',
'tool.knowledge.ingest_proposal',
'tool.db.write_staging',
],
github_metrics_refresh: ['skill.metrics.github-stars.refresh', 'tool.db.write_staging'],
project_ingest: [
'skill.ingest.project.multi-source',
'tool.db.write_staging',
'tool.knowledge.ingest_proposal',
],
}
const ALLOW_PRIORITY: Record<PolicyEffect, number> = {
allow: 3,
ask: 2,
deny: 1,
}
function normalizeText(value: string): string {
return value.toLowerCase().trim()
}
function deriveCapabilityIntentsHeuristic(goal: string): CapabilityIntent[] {
const normalized = normalizeText(goal)
const intents: CapabilityIntent[] = []
if (
normalized.includes('网页') ||
normalized.includes('website') ||
normalized.includes('web') ||
normalized.includes('readme')
) {
intents.push({ capability: 'web_read', reason: 'Goal mentions website exploration.' })
}
if (
normalized.includes('trend') ||
normalized.includes('trending') ||
normalized.includes('daily github') ||
normalized.includes('每日 github')
) {
intents.push({
capability: 'discovery_trending',
reason: 'Goal maps to daily trending discovery workflow.',
})
}
if (normalized.includes('topic') || normalized.includes('话题')) {
intents.push({
capability: 'discovery_topic',
reason: 'Goal maps to topic-based discovery workflow.',
})
}
if (
normalized.includes('signal') ||
normalized.includes('前沿') ||
normalized.includes('聚合') ||
normalized.includes('hot topic')
) {
intents.push({
capability: 'signal_aggregation',
reason: 'Goal maps to multi-source signal aggregation.',
})
}
if (
normalized.includes('代码') ||
normalized.includes('review') ||
normalized.includes('审查') ||
normalized.includes('lint')
) {
intents.push({ capability: 'code_review', reason: 'Goal mentions code quality checks.' })
}
if (normalized.includes('日志') || normalized.includes('log') || normalized.includes('trace')) {
intents.push({ capability: 'log_review', reason: 'Goal requires log or trace analysis.' })
}
if (
normalized.includes('知识') ||
normalized.includes('rag') ||
normalized.includes('memory') ||
normalized.includes('knowledge')
) {
intents.push({
capability: normalized.includes('rag') ? 'rag_search' : 'knowledge_retrieve',
reason: 'Goal references retrieval from knowledge memory layers.',
})
}
if (
normalized.includes('向量') ||
normalized.includes('embedding') ||
normalized.includes('vectorize')
) {
intents.push({
capability: 'embedding_refresh',
reason: 'Goal references vector refresh workflow.',
})
}
if (normalized.includes('star') || normalized.includes('metrics') || normalized.includes('指标')) {
intents.push({
capability: 'github_metrics_refresh',
reason: 'Goal references repository metric refresh.',
})
}
if (
normalized.includes('写入') ||
normalized.includes('ingest') ||
normalized.includes('入库') ||
normalized.includes('内化')
) {
intents.push({
capability: normalized.includes('入库') ? 'project_ingest' : 'knowledge_write',
reason: 'Goal references write-oriented operations.',
})
}
if (intents.length === 0) {
intents.push({
capability: 'knowledge_retrieve',
reason: 'Fallback intent for generic orchestration tasks.',
})
}
const unique = new Map<CapabilityId, CapabilityIntent>()
intents.forEach((intent) => {
if (!unique.has(intent.capability)) {
unique.set(intent.capability, intent)
}
})
return Array.from(unique.values())
}
async function deriveCapabilityIntents(goal: string): Promise<{
intents: CapabilityIntent[]
planning: AgentRun['planning']
summary?: string
suggestedAgentId?: string
riskNotes: string[]
}> {
const config = getAgentModelConfig()
if (!config) {
return {
intents: deriveCapabilityIntentsHeuristic(goal),
planning: {
source: 'heuristic',
warning: 'Model config missing; fallback to heuristic planner.',
},
riskNotes: [],
}
}
try {
const capabilityCatalog = CAPABILITY_IDS.join(', ')
const modelResult = await callAgentModelForJson({
config,
temperature: 0.1,
schema: PlannerResponseSchema,
systemPrompt: [
'You are the Agent Park Gateway planner.',
'Output strictly JSON with this structure: {"intents":[{"capability":"...","reason":"...","context":{}}],"summary":"...","riskNotes":["..."],"suggestedAgentId":"optional"}',
`Allowed capabilities: ${capabilityCatalog}.`,
'Prefer n8n-backed discovery and ingestion capabilities when relevant.',
'Always keep production write/deploy risks explicit in riskNotes.',
'Never use keys other than capability/reason/context inside intents.',
].join(' '),
userPrompt: [
'Convert this goal into capability intents for execution.',
'Goal:',
goal,
'Return concise reasons and include suggestedAgentId only if strong confidence.',
].join('\n'),
})
return {
intents: modelResult.data.intents,
summary: modelResult.data.summary,
suggestedAgentId: modelResult.data.suggestedAgentId,
riskNotes: modelResult.data.riskNotes || [],
planning: {
source: 'model',
model: modelResult.model,
latencyMs: modelResult.latencyMs,
promptTokens: modelResult.promptTokens,
completionTokens: modelResult.completionTokens,
totalTokens: modelResult.totalTokens,
},
}
} catch (error) {
return {
intents: deriveCapabilityIntentsHeuristic(goal),
planning: {
source: 'heuristic',
model: config.model,
warning:
error instanceof Error
? `Model planner failed, fallback to heuristic: ${error.message}`
: 'Model planner failed, fallback to heuristic.',
},
riskNotes: [],
}
}
}
function pickAgentByGoal(input: {
goal: string
intents: CapabilityIntent[]
preferredAgentId?: string
suggestedAgentId?: string
}): AgentManifest {
const configured = listAgentProfiles()
const byId = (id: string | undefined) =>
(id && configured.find((agent) => agent.id === id)) || null
const preferred = byId(input.preferredAgentId)
if (preferred) {
return preferred
}
const suggested = byId(input.suggestedAgentId)
if (suggested) {
return suggested
}
const intentSet = new Set(input.intents.map((intent) => intent.capability))
if (
intentSet.has('discovery_trending') ||
intentSet.has('discovery_topic') ||
intentSet.has('signal_aggregation')
) {
return configured.find((agent) => agent.id === 'agent.discovery.scout') || configured[0]!
}
if (intentSet.has('code_review') || intentSet.has('log_review')) {
return configured.find((agent) => agent.id === 'agent.quality.reviewer') || configured[0]!
}
if (
intentSet.has('knowledge_retrieve') ||
intentSet.has('knowledge_write') ||
intentSet.has('embedding_refresh') ||
intentSet.has('rag_search')
) {
return configured.find((agent) => agent.id === 'agent.knowledge.librarian') || configured[0]!
}
const normalized = normalizeText(input.goal)
if (normalized.includes('discover') || normalized.includes('发现')) {
return configured.find((agent) => agent.id === 'agent.discovery.scout') || configured[0]!
}
return configured.find((agent) => agent.id === 'agent.orchestrator.core') || configured[0]!
}
function extractIntentCommand(intent: CapabilityIntent): string | undefined {
const value = intent.context?.command
return typeof value === 'string' ? value : undefined
}
function extractIntentPaths(intent: CapabilityIntent): string[] | undefined {
const raw = intent.context?.paths
if (!Array.isArray(raw)) {
return undefined
}
return raw.filter((value): value is string => typeof value === 'string')
}
function selectResource(
intent: CapabilityIntent,
agent: AgentManifest,
repeatedCallCount: number
): {
selected?: ResourceManifest
effect: PolicyEffect
reasons: string[]
candidates: string[]
} {
const preferredCandidates = INTENT_TO_RESOURCE_MAP[intent.capability] || []
const allResources = listResourceManifests()
const candidates = preferredCandidates
.map((resourceId) => getResourceManifestById(resourceId))
.filter((resource): resource is ResourceManifest => Boolean(resource))
.filter((resource) => resource.status === 'active')
if (candidates.length === 0) {
const taggedFallback = allResources.filter(
(resource) =>
resource.status === 'active' &&
resource.tags.some((tag) => agent.enabledResourceTags.includes(tag))
)
const fallbackCandidate = taggedFallback[0]
if (!fallbackCandidate) {
return {
effect: 'deny',
reasons: ['no_candidates_for_intent'],
candidates: [],
}
}
const fallbackDecision = evaluatePolicy(agent, fallbackCandidate, {
workspaceRoot: process.cwd(),
repeatedCallCount,
command: extractIntentCommand(intent),
paths: extractIntentPaths(intent),
})
return {
selected: fallbackCandidate,
effect: fallbackDecision.effect,
reasons: [...fallbackDecision.reasons, 'fallback_by_tag_scope'],
candidates: taggedFallback.map((resource) => resource.id),
}
}
let selected: ResourceManifest | undefined
let selectedEffect: PolicyEffect = 'deny'
let selectedReasons: string[] = ['no_allowed_candidate']
for (const candidate of candidates) {
const decision = evaluatePolicy(agent, candidate, {
workspaceRoot: process.cwd(),
repeatedCallCount,
command: extractIntentCommand(intent),
paths: extractIntentPaths(intent),
})
if (
!selected ||
ALLOW_PRIORITY[decision.effect] > ALLOW_PRIORITY[selectedEffect] ||
(ALLOW_PRIORITY[decision.effect] === ALLOW_PRIORITY[selectedEffect] &&
candidate.riskLevel < (selected?.riskLevel || 'L3'))
) {
selected = candidate
selectedEffect = decision.effect
selectedReasons = decision.reasons
}
if (decision.effect === 'allow') {
break
}
}
if (intent.capability === 'web_read' && selected && selected.id !== candidates[0]?.id) {
selectedReasons = [...selectedReasons, `fallback_low_trust:${selected.id}`]
}
return {
selected,
effect: selectedEffect,
reasons: selectedReasons,
candidates: candidates.map((resource) => resource.id),
}
}
function shouldCreateApprovalTicket(selection: {
effect: PolicyEffect
selected?: ResourceManifest
}): boolean {
if (selection.effect === 'ask') {
return true
}
return selection.effect === 'deny' && selection.selected?.riskLevel === 'L3'
}
export async function orchestrateGoal(input: {
goal: string
createdBy: string
preferredAgentId?: string
}) {
const derived = await deriveCapabilityIntents(input.goal)
const agent = pickAgentByGoal({
goal: input.goal,
intents: derived.intents,
preferredAgentId: input.preferredAgentId,
suggestedAgentId: derived.suggestedAgentId,
})
const run = createRun({
goal: input.goal,
createdBy: input.createdBy,
agentId: agent.id,
status: 'PLAN_COMPILED',
notes: ['Run created by Agent Gateway orchestrator.'],
planning: derived.planning,
})
if (derived.summary) {
updateRunStatus(run.id, 'PLAN_COMPILED', `Planner summary: ${derived.summary}`)
}
if (derived.riskNotes.length > 0) {
updateRunStatus(run.id, 'PLAN_COMPILED', `Planner risk notes: ${derived.riskNotes.join(' | ')}`)
}
let pendingApprovals = 0
let hasFailure = false
const approvalTicketIds: string[] = []
derived.intents.forEach((intent, index) => {
const selected = selectResource(intent, agent, index)
const requiresApproval = shouldCreateApprovalTicket(selected)
let taskStatus: 'queued' | 'running' | 'completed' | 'failed' | 'blocked_approval' | 'cancelled'
if (requiresApproval) {
taskStatus = 'blocked_approval'
} else if (selected.effect === 'deny') {
taskStatus = 'failed'
} else {
taskStatus = 'completed'
}
let taskMessage = selected.selected
? `Selected ${selected.selected.id} for ${intent.capability}`
: `No resource selected for ${intent.capability}`
if (requiresApproval) {
const proposal = createPolicyProposal({
title: `Run override request for ${selected.selected?.id || 'unknown-resource'}`,
description: [
`Run ${run.id} requires approval for ${intent.capability}.`,
`Requested resource: ${selected.selected?.id || 'none'}.`,
`Policy decision: ${selected.effect}.`,
].join(' '),
targetType: 'resource',
targetId: selected.selected?.id || 'resource.unknown',
changes: {
runtimeRunId: run.id,
intent: intent.capability,
reasons: selected.reasons,
},
createdBy: input.createdBy,
})
pendingApprovals += 1
approvalTicketIds.push(proposal.approvalTicket.id)
taskMessage += ` (approvalTicket=${proposal.approvalTicket.id})`
}
if (selected.effect === 'deny' && !requiresApproval) {
hasFailure = true
taskMessage += ' (hard denied by policy)'
}
const task = addTask(run.id, {
intent,
status: taskStatus,
selectedResourceId: selected.selected?.id,
policyEffect: selected.effect,
message: taskMessage,
})
addToolCall(run.id, {
taskId: task.id,
agentId: agent.id,
resourceId: selected.selected?.id || 'none',
policyEffect: selected.effect,
success: selected.effect !== 'deny',
inputSummary: `intent=${intent.capability}`,
outputSummary: selected.selected ? `resource=${selected.selected.id}` : 'selection_failed',
reasons: selected.reasons,
})
})
setRunPendingApprovals(run.id, pendingApprovals)
if (pendingApprovals > 0) {
updateRunStatus(
run.id,
'APPROVAL_WAITING',
`Run paused for ${pendingApprovals} approval-required action(s).`
)
} else if (hasFailure) {
updateRunStatus(run.id, 'FAILED', 'Run failed due to policy-denied actions.')
} else {
updateRunStatus(run.id, 'COMPLETED', 'Run completed with policy-safe selections.')
}
addMemoryEntry({
layer: 'working',
key: `run:${run.id}:goal`,
value: input.goal,
evidence: `createdBy=${input.createdBy}`,
createdBy: input.createdBy,
})
addMemoryEntry({
layer: 'project',
key: 'agent_os.last_goal',
value: input.goal,
evidence: `runId=${run.id}`,
createdBy: input.createdBy,
})
if (derived.summary) {
addMemoryEntry({
layer: 'knowledge',
key: `run:${run.id}:planner_summary`,
value: derived.summary,
evidence: `agent=${agent.id}`,
createdBy: input.createdBy,
})
}
return {
runId: run.id,
agent,
intents: derived.intents,
pendingApprovals,
planning: derived.planning,
approvalTicketIds,
}
}
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest'
import { evaluatePolicy } from './policy-engine'
import { getAgentProfileById, getResourceManifestById } from './resource-pool'
function mustGetAgent(agentId: string) {
const agent = getAgentProfileById(agentId)
if (!agent) {
throw new Error(`Missing agent profile: ${agentId}`)
}
return agent
}
function mustGetResource(resourceId: string) {
const resource = getResourceManifestById(resourceId)
if (!resource) {
throw new Error(`Missing resource manifest: ${resourceId}`)
}
return resource
}
describe('agent-os policy engine', () => {
it('denies git push command by command rule', () => {
const agent = mustGetAgent('agent.orchestrator.core')
const resource = mustGetResource('tool.shell.exec')
const result = evaluatePolicy(agent, resource, { command: 'git push origin main' })
expect(result.effect).toBe('deny')
expect(result.reasons.some((reason) => reason.includes('command_rule'))).toBe(true)
})
it('denies .env reads by path rule', () => {
const agent = mustGetAgent('agent.orchestrator.core')
const resource = mustGetResource('tool.fs.read')
const result = evaluatePolicy(agent, resource, { paths: ['/tmp/.env.local'] })
expect(result.effect).toBe('deny')
expect(result.reasons.some((reason) => reason.includes('path_rule'))).toBe(true)
})
it('degrades repeated calls via doom loop guard', () => {
const agent = mustGetAgent('agent.discovery.scout')
const resource = mustGetResource('tool.web.read')
const result = evaluatePolicy(agent, resource, { repeatedCallCount: 5 })
expect(result.effect).toBe('deny')
expect(result.reasons.some((reason) => reason.includes('doom_loop'))).toBe(true)
})
})
+167
View File
@@ -0,0 +1,167 @@
import path from 'path'
import type { AgentManifest, PolicyDecision, PolicyEffect, ResourceManifest } from './types'
interface PolicyContext {
command?: string
paths?: string[]
workspaceRoot?: string
repeatedCallCount?: number
}
const SEVERITY: Record<PolicyEffect, number> = {
allow: 0,
ask: 1,
deny: 2,
}
function chooseMoreStrict(current: PolicyEffect, next: PolicyEffect): PolicyEffect {
return SEVERITY[next] > SEVERITY[current] ? next : current
}
function wildcardToRegex(pattern: string): RegExp {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
const wildcardReplaced = escaped.replace(/\*/g, '.*')
return new RegExp(`^${wildcardReplaced}$`, 'i')
}
function matchesPattern(value: string, pattern: string): boolean {
return wildcardToRegex(pattern).test(value)
}
function evaluateCommandRules(
agent: AgentManifest,
resource: ResourceManifest,
command: string | undefined
): PolicyDecision {
const reasons: string[] = []
let effect: PolicyEffect = resource.defaultPolicy
if (!command) {
return { effect, reasons }
}
for (const rule of agent.commandRules) {
if (rule.resourceId !== resource.id) {
continue
}
if (matchesPattern(command, rule.pattern)) {
effect = chooseMoreStrict(effect, rule.effect)
reasons.push(`command_rule:${rule.pattern}->${rule.effect}`)
}
}
return { effect, reasons }
}
function evaluatePathRules(
agent: AgentManifest,
resource: ResourceManifest,
paths: string[] | undefined
): PolicyDecision {
const reasons: string[] = []
let effect: PolicyEffect = resource.defaultPolicy
if (!paths || paths.length === 0) {
return { effect, reasons }
}
for (const inputPath of paths) {
const baseName = path.basename(inputPath)
for (const rule of agent.pathRules) {
if (rule.resourceId !== resource.id) {
continue
}
if (matchesPattern(baseName, rule.pattern)) {
effect = chooseMoreStrict(effect, rule.effect)
reasons.push(`path_rule:${rule.pattern}->${rule.effect}:${baseName}`)
}
}
}
return { effect, reasons }
}
function evaluateExternalDirectory(
resource: ResourceManifest,
ctx: PolicyContext
): PolicyDecision {
if (!ctx.workspaceRoot || !ctx.paths || ctx.paths.length === 0) {
return { effect: resource.defaultPolicy, reasons: [] }
}
let effect: PolicyEffect = resource.defaultPolicy
const reasons: string[] = []
for (const inputPath of ctx.paths) {
const resolvedPath = path.resolve(inputPath)
const relative = path.relative(ctx.workspaceRoot, resolvedPath)
const outsideWorkspace = relative.startsWith('..') || path.isAbsolute(relative)
if (outsideWorkspace) {
effect = chooseMoreStrict(effect, 'ask')
reasons.push(`external_directory:${resolvedPath}`)
}
}
return { effect, reasons }
}
function evaluateDoomLoop(
resource: ResourceManifest,
repeatedCallCount: number | undefined
): PolicyDecision {
if (!repeatedCallCount || repeatedCallCount < 3) {
return { effect: resource.defaultPolicy, reasons: [] }
}
const reasons = [`doom_loop:repeat_count=${repeatedCallCount}`]
if (repeatedCallCount >= 5) {
return { effect: 'deny', reasons }
}
return { effect: 'ask', reasons }
}
function evaluateAgentOverrides(agent: AgentManifest, resource: ResourceManifest): PolicyDecision {
const override = agent.policyOverrides[resource.id]
if (!override) {
return { effect: resource.defaultPolicy, reasons: [] }
}
return {
effect: chooseMoreStrict(resource.defaultPolicy, override),
reasons: [`agent_override:${override}`],
}
}
export function evaluatePolicy(
agent: AgentManifest,
resource: ResourceManifest,
ctx: PolicyContext = {}
): PolicyDecision {
const reasons: string[] = []
let effect: PolicyEffect = resource.defaultPolicy
const overrideDecision = evaluateAgentOverrides(agent, resource)
effect = chooseMoreStrict(effect, overrideDecision.effect)
reasons.push(...overrideDecision.reasons)
const commandDecision = evaluateCommandRules(agent, resource, ctx.command)
effect = chooseMoreStrict(effect, commandDecision.effect)
reasons.push(...commandDecision.reasons)
const pathDecision = evaluatePathRules(agent, resource, ctx.paths)
effect = chooseMoreStrict(effect, pathDecision.effect)
reasons.push(...pathDecision.reasons)
const externalDirDecision = evaluateExternalDirectory(resource, ctx)
effect = chooseMoreStrict(effect, externalDirDecision.effect)
reasons.push(...externalDirDecision.reasons)
const doomLoopDecision = evaluateDoomLoop(resource, ctx.repeatedCallCount)
effect = chooseMoreStrict(effect, doomLoopDecision.effect)
reasons.push(...doomLoopDecision.reasons)
return {
effect,
reasons,
}
}
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest'
import { getAgentProfileById } from './resource-pool'
import { createPolicyProposal, decideApproval, resetAgentOsStateForTests } from './store'
describe('agent-os runtime policy patch', () => {
it('applies approved proposal patch to agent profile', () => {
resetAgentOsStateForTests()
const before = getAgentProfileById('agent.discovery.scout')
expect(before?.policyOverrides['mcp.agent_browser.read']).toBe('allow')
const proposal = createPolicyProposal({
title: 'Deny MCP browser for scout',
description: 'Test runtime patch',
targetType: 'agent',
targetId: 'agent.discovery.scout',
createdBy: 'tester',
changes: {
policyOverrides: {
'mcp.agent_browser.read': 'deny',
},
},
})
decideApproval({
approvalId: proposal.approvalTicket.id,
decision: 'approved',
decidedBy: 'approver',
comment: 'approved in test',
})
const after = getAgentProfileById('agent.discovery.scout')
expect(after?.policyOverrides['mcp.agent_browser.read']).toBe('deny')
})
})
+110
View File
@@ -0,0 +1,110 @@
import { z } from 'zod'
import type { AgentManifest, PolicyEffect, PolicyProposal } from './types'
type RuntimeAgentPatch = {
policyOverrides?: Record<string, PolicyEffect>
commandRules?: AgentManifest['commandRules']
pathRules?: AgentManifest['pathRules']
enabledResourceTags?: string[]
}
const PolicyEffectSchema = z.enum(['allow', 'ask', 'deny'])
const RuntimePatchSchema = z
.object({
policyOverrides: z.record(PolicyEffectSchema).optional(),
commandRules: z
.array(
z.object({
resourceId: z.string().min(1),
pattern: z.string().min(1),
effect: PolicyEffectSchema,
})
)
.optional(),
pathRules: z
.array(
z.object({
resourceId: z.string().min(1),
pattern: z.string().min(1),
effect: PolicyEffectSchema,
})
)
.optional(),
enabledResourceTags: z.array(z.string().min(1)).optional(),
})
.strict()
declare global {
// eslint-disable-next-line no-var
var __agentParkRuntimePolicyPatchByAgentId: Map<string, RuntimeAgentPatch> | undefined
}
function getRuntimePatchStore(): Map<string, RuntimeAgentPatch> {
if (!globalThis.__agentParkRuntimePolicyPatchByAgentId) {
globalThis.__agentParkRuntimePolicyPatchByAgentId = new Map<string, RuntimeAgentPatch>()
}
return globalThis.__agentParkRuntimePolicyPatchByAgentId
}
function mergePatch(base: RuntimeAgentPatch, incoming: RuntimeAgentPatch): RuntimeAgentPatch {
return {
policyOverrides: {
...(base.policyOverrides || {}),
...(incoming.policyOverrides || {}),
},
commandRules: incoming.commandRules || base.commandRules,
pathRules: incoming.pathRules || base.pathRules,
enabledResourceTags: incoming.enabledResourceTags || base.enabledResourceTags,
}
}
export function applyApprovedProposalPatch(proposal: PolicyProposal): void {
if (proposal.targetType !== 'agent') {
return
}
const parsed = RuntimePatchSchema.safeParse(proposal.changes)
if (!parsed.success) {
return
}
const store = getRuntimePatchStore()
const current = store.get(proposal.targetId) || {}
store.set(proposal.targetId, mergePatch(current, parsed.data))
}
export function getRuntimePatchForAgent(agentId: string): RuntimeAgentPatch | undefined {
return getRuntimePatchStore().get(agentId)
}
export function mergeAgentWithRuntimePatch(agent: AgentManifest): AgentManifest {
const patch = getRuntimePatchForAgent(agent.id)
if (!patch) {
return {
...agent,
policyOverrides: { ...agent.policyOverrides },
commandRules: [...agent.commandRules],
pathRules: [...agent.pathRules],
enabledResourceTags: [...agent.enabledResourceTags],
}
}
return {
...agent,
policyOverrides: {
...agent.policyOverrides,
...(patch.policyOverrides || {}),
},
commandRules: patch.commandRules ? [...patch.commandRules] : [...agent.commandRules],
pathRules: patch.pathRules ? [...patch.pathRules] : [...agent.pathRules],
enabledResourceTags: patch.enabledResourceTags
? [...patch.enabledResourceTags]
: [...agent.enabledResourceTags],
}
}
export function resetRuntimePolicyPatchesForTests(): void {
globalThis.__agentParkRuntimePolicyPatchByAgentId = undefined
}
+646
View File
@@ -0,0 +1,646 @@
import { listN8nBackedSkillManifests, listN8nProductionWorkflowReferences } from './n8n-production-workflows'
import { mergeAgentWithRuntimePatch } from './policy-runtime'
import type {
AgentManifest,
PolicyEffect,
ResourceManifest,
ResourceKind,
} from './types'
const BASE_RESOURCE_MANIFESTS: ResourceManifest[] = [
{
id: 'tool.fs.read',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/read/input',
outputSchemaRef: 'schema://tools/fs/read/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['filesystem', 'read'],
latencyHintMs: 30,
},
{
id: 'tool.fs.list',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/list/input',
outputSchemaRef: 'schema://tools/fs/list/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['filesystem', 'read'],
latencyHintMs: 30,
},
{
id: 'tool.fs.glob',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/glob/input',
outputSchemaRef: 'schema://tools/fs/glob/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['filesystem', 'read'],
latencyHintMs: 40,
},
{
id: 'tool.fs.grep',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/grep/input',
outputSchemaRef: 'schema://tools/fs/grep/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['filesystem', 'read'],
latencyHintMs: 40,
},
{
id: 'tool.fs.write',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/write/input',
outputSchemaRef: 'schema://tools/fs/write/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['filesystem', 'write'],
latencyHintMs: 60,
},
{
id: 'tool.fs.edit',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/edit/input',
outputSchemaRef: 'schema://tools/fs/edit/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['filesystem', 'write'],
latencyHintMs: 60,
},
{
id: 'tool.fs.patch',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/fs/patch/input',
outputSchemaRef: 'schema://tools/fs/patch/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['filesystem', 'write'],
latencyHintMs: 70,
},
{
id: 'tool.shell.exec',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/shell/exec/input',
outputSchemaRef: 'schema://tools/shell/exec/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['shell', 'execution'],
latencyHintMs: 120,
},
{
id: 'tool.git.status_diff',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/git/status_diff/input',
outputSchemaRef: 'schema://tools/git/status_diff/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['git', 'read'],
latencyHintMs: 100,
},
{
id: 'tool.git.branch_commit',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/git/branch_commit/input',
outputSchemaRef: 'schema://tools/git/branch_commit/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['git', 'write'],
latencyHintMs: 120,
},
{
id: 'tool.git.push_pr',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/git/push_pr/input',
outputSchemaRef: 'schema://tools/git/push_pr/output',
riskLevel: 'L3',
defaultPolicy: 'deny',
tags: ['git', 'write', 'production'],
latencyHintMs: 150,
},
{
id: 'tool.build.run',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/build/run/input',
outputSchemaRef: 'schema://tools/build/run/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['build'],
latencyHintMs: 3000,
},
{
id: 'tool.test.run',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/test/run/input',
outputSchemaRef: 'schema://tools/test/run/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['test'],
latencyHintMs: 5000,
},
{
id: 'tool.lint.run',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/lint/run/input',
outputSchemaRef: 'schema://tools/lint/run/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['lint'],
latencyHintMs: 2000,
},
{
id: 'tool.lsp.query',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/lsp/query/input',
outputSchemaRef: 'schema://tools/lsp/query/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['code', 'analysis'],
latencyHintMs: 100,
},
{
id: 'tool.web.read',
kind: 'mcp_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.browser',
inputSchemaRef: 'schema://tools/web/read/input',
outputSchemaRef: 'schema://tools/web/read/output',
riskLevel: 'L1',
defaultPolicy: 'allow',
tags: ['web', 'mcp', 'agent-browser'],
latencyHintMs: 400,
},
{
id: 'tool.web.search',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/web/search/input',
outputSchemaRef: 'schema://tools/web/search/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['web', 'search'],
latencyHintMs: 400,
},
{
id: 'tool.db.query_ro',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/db/query_ro/input',
outputSchemaRef: 'schema://tools/db/query_ro/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['database', 'read'],
latencyHintMs: 80,
},
{
id: 'tool.db.write_staging',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/db/write_staging/input',
outputSchemaRef: 'schema://tools/db/write_staging/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['database', 'write'],
latencyHintMs: 120,
},
{
id: 'tool.db.write_prod',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/db/write_prod/input',
outputSchemaRef: 'schema://tools/db/write_prod/output',
riskLevel: 'L3',
defaultPolicy: 'deny',
tags: ['database', 'write', 'production'],
latencyHintMs: 120,
},
{
id: 'tool.logs.query',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/logs/query/input',
outputSchemaRef: 'schema://tools/logs/query/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['logs'],
latencyHintMs: 150,
},
{
id: 'tool.logs.trace',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/logs/trace/input',
outputSchemaRef: 'schema://tools/logs/trace/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['logs', 'trace'],
latencyHintMs: 180,
},
{
id: 'tool.ci.trigger',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/ci/trigger/input',
outputSchemaRef: 'schema://tools/ci/trigger/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['ci'],
latencyHintMs: 100,
},
{
id: 'tool.deploy.preview',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/deploy/preview/input',
outputSchemaRef: 'schema://tools/deploy/preview/output',
riskLevel: 'L2',
defaultPolicy: 'ask',
tags: ['deploy'],
latencyHintMs: 500,
},
{
id: 'tool.deploy.prod',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/deploy/prod/input',
outputSchemaRef: 'schema://tools/deploy/prod/output',
riskLevel: 'L3',
defaultPolicy: 'deny',
tags: ['deploy', 'production'],
latencyHintMs: 500,
},
{
id: 'tool.skill.load',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/skill/load/input',
outputSchemaRef: 'schema://tools/skill/load/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['skill'],
latencyHintMs: 20,
},
{
id: 'tool.task.spawn',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/task/spawn/input',
outputSchemaRef: 'schema://tools/task/spawn/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['task', 'agent'],
latencyHintMs: 40,
},
{
id: 'tool.memory.read',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/memory/read/input',
outputSchemaRef: 'schema://tools/memory/read/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['memory', 'read'],
latencyHintMs: 25,
},
{
id: 'tool.memory.write_proposal',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/memory/write_proposal/input',
outputSchemaRef: 'schema://tools/memory/write_proposal/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['memory', 'write'],
latencyHintMs: 50,
},
{
id: 'tool.knowledge.retrieve',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/knowledge/retrieve/input',
outputSchemaRef: 'schema://tools/knowledge/retrieve/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['knowledge', 'read'],
latencyHintMs: 100,
},
{
id: 'tool.knowledge.ingest_proposal',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/knowledge/ingest_proposal/input',
outputSchemaRef: 'schema://tools/knowledge/ingest_proposal/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['knowledge', 'write'],
latencyHintMs: 120,
},
{
id: 'tool.human.approval',
kind: 'base_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.os',
inputSchemaRef: 'schema://tools/human/approval/input',
outputSchemaRef: 'schema://tools/human/approval/output',
riskLevel: 'L0',
defaultPolicy: 'allow',
tags: ['approval', 'control'],
latencyHintMs: 20,
},
{
id: 'mcp.agent_browser.read',
kind: 'mcp_tool',
version: '1.0.0',
status: 'active',
owner: 'agent.browser',
inputSchemaRef: 'schema://mcp/agent_browser/read/input',
outputSchemaRef: 'schema://mcp/agent_browser/read/output',
riskLevel: 'L1',
defaultPolicy: 'allow',
tags: ['mcp', 'web', 'agent-browser'],
latencyHintMs: 350,
},
{
id: 'mcp.n8n-mcp',
kind: 'mcp_tool',
version: '1.0.0',
status: 'active',
owner: 'integration.team',
inputSchemaRef: 'schema://mcp/n8n/input',
outputSchemaRef: 'schema://mcp/n8n/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['mcp', 'n8n'],
latencyHintMs: 350,
},
]
const AGENT_PROFILES: AgentManifest[] = [
{
id: 'agent.orchestrator.core',
name: 'Orchestrator Core Agent',
model: 'glm-4.7',
owner: 'platform.team',
budgetDaily: 120,
enabledResourceTags: [
'control',
'approval',
'task',
'memory',
'knowledge',
'skill',
'n8n',
'read',
'analysis',
],
policyOverrides: {
'tool.task.spawn': 'ask',
'tool.shell.exec': 'ask',
'tool.git.push_pr': 'deny',
'tool.db.write_prod': 'deny',
'tool.deploy.prod': 'deny',
},
commandRules: [
{
resourceId: 'tool.shell.exec',
pattern: 'git push *',
effect: 'deny',
},
{
resourceId: 'tool.shell.exec',
pattern: 'git *',
effect: 'allow',
},
],
pathRules: [
{
resourceId: 'tool.fs.read',
pattern: '*.env*',
effect: 'deny',
},
{
resourceId: 'tool.fs.write',
pattern: '*.env*',
effect: 'deny',
},
],
},
{
id: 'agent.discovery.scout',
name: 'Discovery Scout Agent',
model: 'glm-4.7',
owner: 'discovery.team',
budgetDaily: 100,
enabledResourceTags: ['discovery', 'web', 'mcp', 'read', 'skill', 'n8n'],
policyOverrides: {
'mcp.agent_browser.read': 'allow',
'tool.web.read': 'allow',
'tool.db.write_prod': 'deny',
'tool.deploy.prod': 'deny',
},
commandRules: [],
pathRules: [],
},
{
id: 'agent.quality.reviewer',
name: 'Quality Reviewer Agent',
model: 'glm-4.7',
owner: 'quality.team',
budgetDaily: 80,
enabledResourceTags: ['logs', 'read', 'analysis', 'test', 'lint', 'skill', 'n8n'],
policyOverrides: {
'tool.logs.query': 'allow',
'tool.logs.trace': 'allow',
'tool.db.write_prod': 'deny',
'tool.deploy.prod': 'deny',
},
commandRules: [],
pathRules: [],
},
{
id: 'agent.knowledge.librarian',
name: 'Knowledge Librarian Agent',
model: 'glm-4.7',
owner: 'knowledge.team',
budgetDaily: 90,
enabledResourceTags: ['knowledge', 'memory', 'read', 'write', 'skill', 'n8n'],
policyOverrides: {
'tool.knowledge.retrieve': 'allow',
'tool.memory.read': 'allow',
'tool.db.write_prod': 'deny',
'tool.deploy.prod': 'deny',
},
commandRules: [],
pathRules: [],
},
]
const POLICY_PROFILES = [
{
id: 'policy.default.safe',
mode: 'safe',
description: 'Default deny for high-risk operations; approvals required for ask-level actions.',
highRiskRequiresApproval: true,
},
]
function toAgentProfileResource(agent: AgentManifest): ResourceManifest {
return {
id: `profile.${agent.id}`,
kind: 'agent_profile',
version: '1.0.0',
status: 'active',
owner: agent.owner,
inputSchemaRef: 'schema://agent_profile/input',
outputSchemaRef: 'schema://agent_profile/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['agent', 'profile'],
latencyHintMs: 0,
}
}
function toPolicyProfileResource(
policyProfile: (typeof POLICY_PROFILES)[number]
): ResourceManifest {
return {
id: `profile.${policyProfile.id}`,
kind: 'policy_profile',
version: '1.0.0',
status: 'active',
owner: 'platform.team',
inputSchemaRef: 'schema://policy_profile/input',
outputSchemaRef: 'schema://policy_profile/output',
riskLevel: 'L1',
defaultPolicy: 'ask',
tags: ['policy', 'profile'],
latencyHintMs: 0,
}
}
function listUnifiedResources(): ResourceManifest[] {
const agentProfileResources = listAgentProfiles().map((agent) => toAgentProfileResource(agent))
const policyProfileResources = POLICY_PROFILES.map((policy) => toPolicyProfileResource(policy))
const n8nSkillResources = listN8nBackedSkillManifests()
return [...BASE_RESOURCE_MANIFESTS, ...n8nSkillResources, ...agentProfileResources, ...policyProfileResources]
}
export function listResourceManifests(kind?: ResourceKind): ResourceManifest[] {
const resources = listUnifiedResources()
if (!kind) {
return resources
}
return resources.filter((resource) => resource.kind === kind)
}
export function getResourceManifestById(resourceId: string): ResourceManifest | null {
return listUnifiedResources().find((resource) => resource.id === resourceId) || null
}
export function listAgentProfiles(): AgentManifest[] {
return AGENT_PROFILES.map((profile) => mergeAgentWithRuntimePatch(profile))
}
export function getAgentProfileById(agentId: string): AgentManifest | null {
return listAgentProfiles().find((profile) => profile.id === agentId) || null
}
export function listPolicyProfiles() {
return POLICY_PROFILES.map((profile) => ({ ...profile }))
}
export function listDefaultToolsPolicy(): Array<{
id: string
defaultPolicy: PolicyEffect
riskLevel: ResourceManifest['riskLevel']
}> {
return BASE_RESOURCE_MANIFESTS.filter(
(resource) => resource.kind === 'base_tool' || resource.kind === 'mcp_tool'
).map((resource) => ({
id: resource.id,
defaultPolicy: resource.defaultPolicy,
riskLevel: resource.riskLevel,
}))
}
export function listN8nProductionWorkflows() {
return listN8nProductionWorkflowReferences()
}
+35
View File
@@ -0,0 +1,35 @@
import { z } from 'zod'
export const AdminAuthTokenRequestSchema = z.object({
adminKey: z.string().min(32),
operator: z.string().min(2).max(80).optional(),
})
export const CreatePolicyProposalRequestSchema = z.object({
title: z.string().min(3).max(200),
description: z.string().min(5).max(3000),
targetType: z.enum(['agent', 'resource', 'policy_profile']),
targetId: z.string().min(1).max(200),
changes: z.record(z.unknown()),
})
export const ApprovalDecisionRequestSchema = z.object({
decision: z.enum(['approved', 'rejected']),
comment: z.string().max(1000).optional(),
})
export const AgentGoalRequestSchema = z.object({
goal: z.string().min(3).max(5000),
preferredAgentId: z.string().max(200).optional(),
})
export const RunControlRequestSchema = z.object({
action: z.enum(['pause', 'resume', 'rollback', 'fail', 'complete']),
note: z.string().max(1000).optional(),
})
export type AdminAuthTokenRequest = z.infer<typeof AdminAuthTokenRequestSchema>
export type CreatePolicyProposalRequest = z.infer<typeof CreatePolicyProposalRequestSchema>
export type ApprovalDecisionRequest = z.infer<typeof ApprovalDecisionRequestSchema>
export type AgentGoalRequest = z.infer<typeof AgentGoalRequestSchema>
export type RunControlRequest = z.infer<typeof RunControlRequestSchema>
+331
View File
@@ -0,0 +1,331 @@
import crypto from 'crypto'
import { applyApprovedProposalPatch, resetRuntimePolicyPatchesForTests } from './policy-runtime'
import type {
AgentRun,
AgentTask,
ApprovalStatus,
ApprovalTicket,
MemoryEntry,
PolicyProposal,
ProposalStatus,
RunStatus,
ToolCallAuditRecord,
} from './types'
interface AgentOsState {
runs: Map<string, AgentRun>
tasksByRun: Map<string, AgentTask[]>
toolCallsByRun: Map<string, ToolCallAuditRecord[]>
proposals: Map<string, PolicyProposal>
approvalTickets: Map<string, ApprovalTicket>
memoryEntries: MemoryEntry[]
}
declare global {
// eslint-disable-next-line no-var
var __agentParkAgentOsState: AgentOsState | undefined
}
function nowIso(): string {
return new Date().toISOString()
}
function getState(): AgentOsState {
if (!globalThis.__agentParkAgentOsState) {
globalThis.__agentParkAgentOsState = {
runs: new Map<string, AgentRun>(),
tasksByRun: new Map<string, AgentTask[]>(),
toolCallsByRun: new Map<string, ToolCallAuditRecord[]>(),
proposals: new Map<string, PolicyProposal>(),
approvalTickets: new Map<string, ApprovalTicket>(),
memoryEntries: [],
}
}
return globalThis.__agentParkAgentOsState
}
export function createRun(input: {
goal: string
createdBy: string
agentId: string
status?: RunStatus
notes?: string[]
planning?: AgentRun['planning']
}): AgentRun {
const state = getState()
const timestamp = nowIso()
const run: AgentRun = {
id: crypto.randomUUID(),
goal: input.goal,
createdBy: input.createdBy,
agentId: input.agentId,
status: input.status || 'GOAL_ACCEPTED',
createdAt: timestamp,
updatedAt: timestamp,
pendingApprovals: 0,
notes: input.notes || [],
planning: input.planning,
}
state.runs.set(run.id, run)
state.tasksByRun.set(run.id, [])
state.toolCallsByRun.set(run.id, [])
return run
}
export function updateRunStatus(runId: string, status: RunStatus, note?: string): AgentRun | null {
const state = getState()
const run = state.runs.get(runId)
if (!run) {
return null
}
const next: AgentRun = {
...run,
status,
updatedAt: nowIso(),
notes: note ? [...run.notes, note] : run.notes,
}
state.runs.set(runId, next)
return next
}
export function listRuns(): AgentRun[] {
const state = getState()
return Array.from(state.runs.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}
export function getRun(runId: string): AgentRun | null {
return getState().runs.get(runId) || null
}
export function addTask(
runId: string,
task: Omit<AgentTask, 'id' | 'createdAt' | 'updatedAt' | 'runId'>
): AgentTask {
const state = getState()
const timestamp = nowIso()
const createdTask: AgentTask = {
...task,
id: crypto.randomUUID(),
runId,
createdAt: timestamp,
updatedAt: timestamp,
}
const existing = state.tasksByRun.get(runId) || []
state.tasksByRun.set(runId, [...existing, createdTask])
return createdTask
}
export function updateTaskStatus(runId: string, taskId: string, status: AgentTask['status']): AgentTask | null {
const state = getState()
const tasks = state.tasksByRun.get(runId)
if (!tasks) {
return null
}
let updatedTask: AgentTask | null = null
const nextTasks = tasks.map((task) => {
if (task.id !== taskId) {
return task
}
updatedTask = {
...task,
status,
updatedAt: nowIso(),
}
return updatedTask
})
state.tasksByRun.set(runId, nextTasks)
return updatedTask
}
export function listTasks(runId?: string): AgentTask[] {
const state = getState()
if (runId) {
return [...(state.tasksByRun.get(runId) || [])]
}
return Array.from(state.tasksByRun.values()).flat()
}
export function addToolCall(
runId: string,
record: Omit<ToolCallAuditRecord, 'id' | 'createdAt' | 'runId'>
): ToolCallAuditRecord {
const state = getState()
const created: ToolCallAuditRecord = {
...record,
id: crypto.randomUUID(),
runId,
createdAt: nowIso(),
}
const existing = state.toolCallsByRun.get(runId) || []
state.toolCallsByRun.set(runId, [...existing, created])
return created
}
export function listToolCalls(runId?: string): ToolCallAuditRecord[] {
const state = getState()
if (runId) {
return [...(state.toolCallsByRun.get(runId) || [])]
}
return Array.from(state.toolCallsByRun.values()).flat()
}
export function createPolicyProposal(input: {
title: string
description: string
targetType: PolicyProposal['targetType']
targetId: string
changes: Record<string, unknown>
createdBy: string
}): { proposal: PolicyProposal; approvalTicket: ApprovalTicket } {
const state = getState()
const timestamp = nowIso()
const proposal: PolicyProposal = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
targetType: input.targetType,
targetId: input.targetId,
changes: input.changes,
status: 'pending_approval',
createdBy: input.createdBy,
createdAt: timestamp,
updatedAt: timestamp,
}
const approvalTicket: ApprovalTicket = {
id: crypto.randomUUID(),
proposalId: proposal.id,
status: 'pending',
requestedBy: input.createdBy,
createdAt: timestamp,
updatedAt: timestamp,
}
proposal.approvalTicketId = approvalTicket.id
state.proposals.set(proposal.id, proposal)
state.approvalTickets.set(approvalTicket.id, approvalTicket)
return { proposal, approvalTicket }
}
export function listPolicyProposals(): PolicyProposal[] {
return Array.from(getState().proposals.values()).sort((a, b) => b.createdAt.localeCompare(a.createdAt))
}
export function getApprovalTicket(approvalId: string): ApprovalTicket | null {
return getState().approvalTickets.get(approvalId) || null
}
export function listApprovalTickets(): ApprovalTicket[] {
return Array.from(getState().approvalTickets.values()).sort((a, b) =>
b.createdAt.localeCompare(a.createdAt)
)
}
export function decideApproval(input: {
approvalId: string
decision: ApprovalStatus
decidedBy: string
comment?: string
}): { approvalTicket: ApprovalTicket; proposal?: PolicyProposal } | null {
const state = getState()
const ticket = state.approvalTickets.get(input.approvalId)
if (!ticket) {
return null
}
const timestamp = nowIso()
const nextTicket: ApprovalTicket = {
...ticket,
status: input.decision,
updatedAt: timestamp,
decidedBy: input.decidedBy,
decisionComment: input.comment,
}
state.approvalTickets.set(input.approvalId, nextTicket)
const proposal = state.proposals.get(ticket.proposalId)
if (!proposal) {
return { approvalTicket: nextTicket }
}
const proposalStatus: ProposalStatus = input.decision === 'approved' ? 'approved' : 'rejected'
const nextProposal: PolicyProposal = {
...proposal,
status: proposalStatus,
updatedAt: timestamp,
}
state.proposals.set(nextProposal.id, nextProposal)
if (proposalStatus === 'approved') {
applyApprovedProposalPatch(nextProposal)
}
return {
approvalTicket: nextTicket,
proposal: nextProposal,
}
}
export function addMemoryEntry(input: Omit<MemoryEntry, 'id' | 'createdAt'>): MemoryEntry {
const state = getState()
const entry: MemoryEntry = {
...input,
id: crypto.randomUUID(),
createdAt: nowIso(),
}
state.memoryEntries.push(entry)
return entry
}
export function listMemoryEntries(layer?: MemoryEntry['layer']): MemoryEntry[] {
const entries = getState().memoryEntries
if (!layer) {
return [...entries]
}
return entries.filter((entry) => entry.layer === layer)
}
export function setRunPendingApprovals(runId: string, pendingApprovals: number): AgentRun | null {
const state = getState()
const run = state.runs.get(runId)
if (!run) {
return null
}
const next: AgentRun = {
...run,
pendingApprovals,
updatedAt: nowIso(),
status: pendingApprovals > 0 ? 'APPROVAL_WAITING' : run.status,
}
state.runs.set(runId, next)
return next
}
export function resetAgentOsStateForTests(): void {
globalThis.__agentParkAgentOsState = undefined
resetRuntimePolicyPatchesForTests()
}
+191
View File
@@ -0,0 +1,191 @@
export type ResourceKind =
| 'base_tool'
| 'mcp_tool'
| 'skill'
| 'agent_profile'
| 'policy_profile'
export type ResourceStatus = 'active' | 'disabled' | 'deprecated'
export type RiskLevel = 'L0' | 'L1' | 'L2' | 'L3'
export type PolicyEffect = 'allow' | 'ask' | 'deny'
export const CAPABILITY_IDS = [
'web_read',
'code_review',
'log_review',
'knowledge_retrieve',
'knowledge_write',
'task_spawn',
'discovery_trending',
'discovery_topic',
'signal_aggregation',
'rag_search',
'embedding_refresh',
'github_metrics_refresh',
'project_ingest',
] as const
export type CapabilityId = (typeof CAPABILITY_IDS)[number]
export interface ResourceManifest {
id: string
kind: ResourceKind
version: string
status: ResourceStatus
owner: string
inputSchemaRef: string
outputSchemaRef: string
riskLevel: RiskLevel
defaultPolicy: PolicyEffect
tags: string[]
costHint?: number
latencyHintMs?: number
metadata?: Record<string, unknown>
}
export interface CapabilityIntent {
capability: CapabilityId
reason: string
context?: Record<string, unknown>
}
export interface ToolSelectionDecision {
intent: CapabilityIntent
selectedResourceId?: string
candidateResourceIds: string[]
effect: PolicyEffect
reasons: string[]
}
export interface AgentManifest {
id: string
name: string
model: string
owner: string
budgetDaily: number
enabledResourceTags: string[]
policyOverrides: Record<string, PolicyEffect>
commandRules: Array<{
resourceId: string
pattern: string
effect: PolicyEffect
}>
pathRules: Array<{
resourceId: string
pattern: string
effect: PolicyEffect
}>
}
export type RunStatus =
| 'GOAL_ACCEPTED'
| 'PLAN_COMPILED'
| 'TASK_DISPATCHED'
| 'TOOL_EXECUTING'
| 'EVIDENCE_VALIDATING'
| 'QUALITY_REVIEWING'
| 'MEMORY_COMMITTING'
| 'APPROVAL_WAITING'
| 'COMPLETED'
| 'FAILED'
| 'PAUSED'
| 'ROLLED_BACK'
export type TaskStatus =
| 'queued'
| 'running'
| 'completed'
| 'failed'
| 'blocked_approval'
| 'cancelled'
export interface AgentRun {
id: string
goal: string
createdBy: string
agentId: string
status: RunStatus
createdAt: string
updatedAt: string
pendingApprovals: number
notes: string[]
planning?: {
source: 'model' | 'heuristic'
model?: string
latencyMs?: number
promptTokens?: number
completionTokens?: number
totalTokens?: number
warning?: string
}
}
export interface AgentTask {
id: string
runId: string
intent: CapabilityIntent
status: TaskStatus
selectedResourceId?: string
policyEffect: PolicyEffect
createdAt: string
updatedAt: string
message: string
}
export interface ToolCallAuditRecord {
id: string
runId: string
taskId: string
agentId: string
resourceId: string
policyEffect: PolicyEffect
success: boolean
inputSummary: string
outputSummary: string
reasons: string[]
createdAt: string
}
export type ApprovalStatus = 'pending' | 'approved' | 'rejected'
export interface ApprovalTicket {
id: string
proposalId: string
status: ApprovalStatus
createdAt: string
updatedAt: string
requestedBy: string
decidedBy?: string
decisionComment?: string
}
export type ProposalStatus = 'draft' | 'pending_approval' | 'approved' | 'rejected'
export interface PolicyProposal {
id: string
title: string
description: string
targetType: 'agent' | 'resource' | 'policy_profile'
targetId: string
changes: Record<string, unknown>
status: ProposalStatus
createdBy: string
createdAt: string
updatedAt: string
approvalTicketId?: string
}
export interface MemoryEntry {
id: string
layer: 'working' | 'project' | 'knowledge'
key: string
value: string
evidence?: string
createdAt: string
createdBy: string
}
export interface PolicyDecision {
effect: PolicyEffect
reasons: string[]
}
+90 -2
View File
@@ -122,7 +122,21 @@
"paginationLast": "Last page",
"paginationSummary": "Page {current} / {totalPages} · {total} total",
"showFilterPanel": "Show Filters",
"hideFilterPanel": "Hide Filters"
"hideFilterPanel": "Hide Filters",
"relatedProjects": "Related Projects",
"viewAll": "View All",
"addedOn": "Added {date}",
"noContentAvailable": "No content available.",
"githubStatsTitle": "GitHub Statistics",
"githubStatsViewStars": "View stars on GitHub",
"githubStatsViewForks": "View forks on GitHub",
"githubStatsViewIssues": "View issues on GitHub",
"githubStatsViewLicense": "View license on GitHub",
"shareTweetText": "Check out {name} on Agent Park",
"shareOnX": "Share on X",
"copyLink": "Copy Link",
"feedbackQuestion": "Did this agent help you?",
"feedbackYes": "Yes, it helped"
},
"about": {
"metaTitle": "About Agent Park",
@@ -215,7 +229,8 @@
"projects": "Projects",
"signals": "Signals",
"about": "About",
"submitProject": "SUBMIT PROJECT"
"submitProject": "SUBMIT PROJECT",
"admin": "Admin"
},
"notFound": {
"title": "Page Not Found",
@@ -233,11 +248,84 @@
"subscribeConsent": "Subscribe to Agent Park updates",
"footerDesc": "Discover and explore high-quality AI projects from across the web. Curated for developers and enthusiasts.",
"resources": "Resources",
"resourceNewsletter": "Newsletter",
"resourceUpdates": "Updates",
"resourceDocumentation": "Documentation",
"legal": "Legal",
"privacyPolicy": "Privacy Policy",
"termsOfService": "Terms of Service",
"followUs": "Follow Us",
"closeAnnouncement": "Close announcement",
"copyright": "© 2025 Agent Park. All rights reserved.",
"designedFor": "DESIGNED FOR AI BUILDERS"
},
"admin": {
"title": "Agent Control Console",
"subtitle": "Unified operations for agent goal orchestration, resource pools (Skill/MCP/tools), policy proposal approvals, and runtime audit logs.",
"navChat": "Agent Chat",
"navResources": "Resource Pool",
"navPolicies": "Policy Proposals",
"navRuns": "Run Ops",
"navLogs": "Audit Logs",
"cardChatTitle": "Orchestrator Chat",
"cardChatDescription": "Submit goals and let the gateway derive intents and select resources.",
"cardResourceTitle": "Resource Pool Management",
"cardResourceDescription": "Inspect and govern tools, MCP bindings, skills, and agent profiles.",
"cardPolicyTitle": "Policy Proposal Approval",
"cardPolicyDescription": "Manage access policy changes via proposal -> approval -> activation flow.",
"cardLogsTitle": "Audit Logs",
"cardLogsDescription": "Inspect task-level and tool-call-level runtime records.",
"goalDefault": "Explore agent projects on GitHub, and run code and log quality checks.",
"unknownError": "Unknown error",
"goalConsoleTitle": "Agent Gateway Chat Console",
"goalConsoleDescription": "Submit a high-level goal. The orchestrator derives capability intents, selects resources from the pool, and records run/task/tool-call audit trails.",
"adminKeyLabel": "Admin key",
"adminKeyPlaceholder": "ADMIN_CONSOLE_KEY",
"goalLabel": "Goal",
"submitting": "Submitting...",
"submitGoal": "Submit Goal",
"noRunYet": "No run result yet.",
"requestJsonLabel": "Request JSON",
"loading": "Loading...",
"executeEndpoint": "Execute {method} {endpoint}",
"noResponseYet": "No response yet.",
"policyDefaultTitle": "Block orchestrator git push",
"policyDefaultDescription": "Tighten shell command policy for orchestrator and deny git push patterns.",
"policyTitle": "Policy Proposal & Approval",
"proposalJsonLabel": "Proposal JSON",
"submitProposal": "Submit Proposal",
"approvalDecisionTitle": "Approval Decision",
"approvalTicketIdPlaceholder": "Approval ticket ID",
"decisionCommentPlaceholder": "Approval comment",
"approve": "Approve",
"reject": "Reject",
"logsTaskTitle": "Task Logs",
"logsTaskDescription": "Task-level runtime audit stream.",
"logsToolCallTitle": "Tool Call Logs",
"logsToolCallDescription": "Tool-call-level audit records with policy decisions.",
"policyProposalsTitle": "Policy Proposal List",
"policyProposalsDescription": "Inspect policy proposals and approval status.",
"approvalTicketsTitle": "Approval Ticket List",
"approvalTicketsDescription": "Inspect proposal-to-ticket bindings.",
"agentsTitle": "Agent Profiles",
"agentsDescription": "Inspect Agent Manifest, budget, tag domains, and policy overrides.",
"mcpTitle": "MCP Resource Bindings",
"mcpDescription": "Inspect MCP tools in the resource pool and Agent Browser priority settings.",
"skillsTitle": "Skill Registry",
"skillsDescription": "Inspect skill inventory and versions in the pool.",
"toolsTitle": "Base Tools + MCP Tools",
"toolsDescription": "Inspect tool risk levels and default allow/ask/deny strategy.",
"resourcesTabTools": "Tools",
"resourcesTabSkills": "Skills",
"resourcesTabAgents": "Agents",
"resourcesOverviewTitle": "Resource Pool Overview",
"resourcesOverviewDescription": "Unified view of base tools, MCP tools, skills, agent profiles, and policy profiles.",
"runDetailTitle": "Run Detail: {id}",
"runDetailDescription": "Inspect intent, task, and tool-call audit records for this run.",
"runsListTitle": "Run List",
"runsListDescription": "Inspect gateway run instances and orchestration status.",
"runsControlTitle": "Run Control (POST)",
"runsControlDescription": "Replace REPLACE_RUN_ID in endpoint with a real run ID before control actions.",
"runsControlDefaultNote": "Manual pause from admin console"
}
}
+90 -2
View File
@@ -122,7 +122,21 @@
"paginationLast": "末页",
"paginationSummary": "第 {current} / {totalPages} 页 · 共 {total} 条",
"showFilterPanel": "展开筛选器",
"hideFilterPanel": "收起筛选器"
"hideFilterPanel": "收起筛选器",
"relatedProjects": "相关项目",
"viewAll": "查看全部",
"addedOn": "收录于 {date}",
"noContentAvailable": "暂无内容。",
"githubStatsTitle": "GitHub 统计",
"githubStatsViewStars": "在 GitHub 查看 Star",
"githubStatsViewForks": "在 GitHub 查看 Fork",
"githubStatsViewIssues": "在 GitHub 查看 Issues",
"githubStatsViewLicense": "在 GitHub 查看许可证",
"shareTweetText": "来 Agent Park 看看 {name}",
"shareOnX": "分享到 X",
"copyLink": "复制链接",
"feedbackQuestion": "这个 Agent 对你有帮助吗?",
"feedbackYes": "有,挺有用"
},
"about": {
"metaTitle": "关于 Agent Park",
@@ -215,7 +229,8 @@
"projects": "项目列表",
"signals": "前沿信号",
"about": "关于",
"submitProject": "提交项目"
"submitProject": "提交项目",
"admin": "管理台"
},
"notFound": {
"title": "页面未找到",
@@ -233,11 +248,84 @@
"subscribeConsent": "订阅 Agent Park 更新",
"footerDesc": "发现和探索来自全网的高质量 AI 项目。为开发者和爱好者精心策划。",
"resources": "资源",
"resourceNewsletter": "新闻订阅",
"resourceUpdates": "更新日志",
"resourceDocumentation": "文档",
"legal": "法律",
"privacyPolicy": "隐私政策",
"termsOfService": "服务条款",
"followUs": "关注我们",
"closeAnnouncement": "关闭公告",
"copyright": "© 2025 Agent Park. 保留所有权利。",
"designedFor": "专为 AI 构建者设计"
},
"admin": {
"title": "Agent 管理控制台",
"subtitle": "统一管理 Agent 目标编排、资源池(Skill/MCP/工具)、策略提案审批与运行日志审计。",
"navChat": "Agent 对话",
"navResources": "资源池",
"navPolicies": "策略提案",
"navRuns": "运行管理",
"navLogs": "运行日志",
"cardChatTitle": "总控 Agent 对话",
"cardChatDescription": "下发目标,由 Gateway 自动拆解能力意图并选择资源。",
"cardResourceTitle": "资源池管理",
"cardResourceDescription": "查看并治理工具、MCP、Skill 与 Agent Profile。",
"cardPolicyTitle": "策略提案审批",
"cardPolicyDescription": "按“提案 -> 审批 -> 生效”流程管理权限与策略。",
"cardLogsTitle": "日志审计",
"cardLogsDescription": "查看任务级与工具调用级审计记录。",
"goalDefault": "探索 GitHub 上的 Agent 项目,并进行代码与日志质量检查。",
"unknownError": "未知错误",
"goalConsoleTitle": "Agent 网关对话控制台",
"goalConsoleDescription": "提交高层目标后,编排器会自动拆解能力意图、从资源池选择工具,并记录 run/task/tool-call 审计链。",
"adminKeyLabel": "管理员密钥",
"adminKeyPlaceholder": "ADMIN_CONSOLE_KEY",
"goalLabel": "目标",
"submitting": "提交中...",
"submitGoal": "提交目标",
"noRunYet": "暂无运行结果。",
"requestJsonLabel": "请求 JSON",
"loading": "加载中...",
"executeEndpoint": "执行 {method} {endpoint}",
"noResponseYet": "暂无响应。",
"policyDefaultTitle": "禁止编排器执行 git push",
"policyDefaultDescription": "收紧编排器的 shell 命令策略,拒绝 git push 模式。",
"policyTitle": "策略提案与审批",
"proposalJsonLabel": "提案 JSON",
"submitProposal": "提交提案",
"approvalDecisionTitle": "审批决策",
"approvalTicketIdPlaceholder": "审批单 ID",
"decisionCommentPlaceholder": "审批备注",
"approve": "批准",
"reject": "驳回",
"logsTaskTitle": "任务日志",
"logsTaskDescription": "任务级运行审计流。",
"logsToolCallTitle": "工具调用日志",
"logsToolCallDescription": "包含策略决策的工具调用级审计记录。",
"policyProposalsTitle": "策略提案列表",
"policyProposalsDescription": "查看策略提案及其当前审批状态。",
"approvalTicketsTitle": "审批单列表",
"approvalTicketsDescription": "查看提案与审批单绑定关系。",
"agentsTitle": "Agent 配置",
"agentsDescription": "查看 Agent Manifest、预算、标签域与策略覆盖。",
"mcpTitle": "MCP 资源绑定",
"mcpDescription": "查看资源池中的 MCP 工具及 Agent Browser 优先读取配置。",
"skillsTitle": "Skill 注册表",
"skillsDescription": "查看资源池中 Skill 清单与版本。",
"toolsTitle": "基础工具 + MCP 工具",
"toolsDescription": "查看工具风险等级与默认 allow/ask/deny 策略。",
"resourcesTabTools": "工具",
"resourcesTabSkills": "技能",
"resourcesTabAgents": "Agent",
"resourcesOverviewTitle": "资源池总览",
"resourcesOverviewDescription": "统一查看基础工具、MCP 工具、技能、Agent 配置与策略配置。",
"runDetailTitle": "运行详情:{id}",
"runDetailDescription": "查看运行的意图、任务和工具调用审计记录。",
"runsListTitle": "运行列表",
"runsListDescription": "查看网关运行实例及当前编排状态。",
"runsControlTitle": "运行控制(POST",
"runsControlDescription": "执行控制动作前,请先将 endpoint 中的 REPLACE_RUN_ID 替换为真实运行 ID。",
"runsControlDefaultNote": "来自管理台的手动暂停"
}
}