From 1569dad4fcab6461ac28a2931c88315f1df00c44 Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 25 Aug 2026 18:59:15 +0800 Subject: [PATCH] feat(open-api): 加全局 agent 的外部调用入口 --- server/routes/open-api.js | 72 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------- server/tests/unit/open-api-global-agent-invoke.test.js | 70 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 server/tests/unit/open-api-global-agent-invoke.test.js diff --git a/server/routes/open-api.js b/server/routes/open-api.js index 05a0c74..7ce23b0 100644 --- a/server/routes/open-api.js +++ b/server/routes/open-api.js @@ -312,6 +312,47 @@ router.post('/companies/:companyId/agents/:agentId/invoke', h(async (req, res) = }); })); +// ── Global (System-level) Agent Invocation ── +// 面向不挂任何公司的全局 agent(master / agent-creator / h3-video-prompt 这类系统级 +// specialist)。company-scoped 的 /companies/:companyId/agents/:agentId/invoke 强制查 +// company_agents「雇没雇」,全局 agent 从来不属于任何公司,套那条路一定 404。 +// 这条不查雇佣关系,只查本体是否存在,会话落公司态目录的 `_global` 保留位 +// (agent-locator 的既定行为,跟 heartbeat-service/agent-chat.js 里的全局 agent 是同一回事)。 +router.post('/agents/:agentId/invoke', h(async (req, res) => { + const { agentId } = req.params; + const { task, contextFiles, webhookUrl } = req.body; + + if (!task || typeof task !== 'string') { + return res.status(400).json({ error: 'Bad Request', message: 'The "task" field is required and must be a string.' }); + } + if (agentId.includes(':')) { + return res.status(400).json({ error: 'Bad Request', message: 'This endpoint is for global agents only (no "companyId:role" prefix). Use /companies/:companyId/agents/:agentId/invoke for company-hired agents.' }); + } + const locator = require('../services/agent-locator'); + if (!fs.existsSync(path.join(locator.bodyDir(agentId), 'config.json'))) { + return res.status(404).json({ error: `Agent "${agentId}" not found` }); + } + + const job = jobManager.createJob({ + description: `Invoke global agent ${agentId}`, + webhookUrl, + apiKey: req.apiKey, + user_id: req.user?.id + }); + job.agentId = agentId; + jobManager.updateJob(job.jobId, { status: 'queued', progress: 'Task is waiting for execution queue...' }); + + _runGlobalAgentInvocation(job, { agentId, task, contextFiles }).catch(err => { + jobManager.updateJob(job.jobId, { status: 'failed', error: err.message, progress: `Invocation failed: ${err.message}` }); + }); + + res.status(202).json({ + jobId: job.jobId, + status: job.status, + message: `Agent invocation job queued. Poll GET /api/open/jobs/${job.jobId} for status.` + }); +})); + // ── Pipelines (简化版) ── // 读取 .data/pipelines.json let pipelinesData = { pipelines: [] }; @@ -533,11 +574,6 @@ async function _runSkillCreation(job, resumeFromStep = null) { async function _runAgentInvocation(job, payload) { const { companyId, agentId, task, contextFiles } = payload; - const path = require('path'); - const fs = require('fs'); - const { loadConfigFromSkills } = require('../src/core/skill-config-loader'); - const AgentManager = require('../src/core/agent-manager'); - const config = require('../config'); // 「这家公司有没有这个 agent」查 company_agents(28 批次 4-19)。 // 原来判的是公司私有目录存不存在 —— 那个目录整体没了,再判就是无条件报 404。 @@ -550,6 +586,27 @@ async function _runAgentInvocation(job, payload) { // 附件落公司态目录(长期,跨任务),不落任务态 —— 外部系统传进来的上下文 // 不属于某一次 run,run 目录会按保留策略清掉(28 §5.6)。 const agentDir = locator.companyDir(hired.system_agent_id, companyId); + const namespacedId = `${companyId}:${agentId}`; + + await _executeAgentInvocation(job, { agentDir, namespacedId, task, contextFiles }); +} + +// 全局 agent(不挂任何公司,落 agent-locator 的 `_global` 保留位)版本 —— 无雇佣关系可查, +// 本体存在性已经在路由层校验过。 +async function _runGlobalAgentInvocation(job, payload) { + const { agentId, task, contextFiles } = payload; + const locator = require('../services/agent-locator'); + const agentDir = locator.companyDir(agentId); // 不传 companyId 落 _global + await _executeAgentInvocation(job, { agentDir, namespacedId: agentId, task, contextFiles }); +} + +// 两条 invoke 路径共用的核心:处理附件、跑 AgentManager、写 job 结果。 +async function _executeAgentInvocation(job, { agentDir, namespacedId, task, contextFiles }) { + const path = require('path'); + const fs = require('fs'); + const { loadConfigFromSkills } = require('../src/core/skill-config-loader'); + const AgentManager = require('../src/core/agent-manager'); + const config = require('../config'); jobManager.updateJob(job.jobId, { status: 'running', progress: 'Initializing agent session...' }); @@ -592,9 +649,7 @@ async function _runAgentInvocation(job, payload) { const fullSystemConfig = loadConfigFromSkills(skillsDir, backends); const manager = new AgentManager(fullSystemConfig, rootDir); - - const namespacedId = `${companyId}:${agentId}`; - + // Execute via AgentManager jobManager.updateJob(job.jobId, { status: 'running', progress: 'Executing agent task...' }); @@ -629,3 +684,4 @@ module.exports._jobManager = jobManager; module.exports._runSkillCreation = _runSkillCreation; module.exports._runFeedbackIteration = _runFeedbackIteration; module.exports._runAgentInvocation = _runAgentInvocation; +module.exports._runGlobalAgentInvocation = _runGlobalAgentInvocation; diff --git a/server/tests/unit/open-api-global-agent-invoke.test.js b/server/tests/unit/open-api-global-agent-invoke.test.js new file mode 100644 index 0000000..08ebb8e --- /dev/null +++ b/server/tests/unit/open-api-global-agent-invoke.test.js @@ -0,0 +1,70 @@ +/** + * POST /api/open/agents/:agentId/invoke — 全局 agent 的外部调用入口(不挂公司)。 + * 只测路由层的同步校验分支(task 必填 / 拒绝 companyId:role 形态 / 本体不存在 404), + * 不触发真正的 AgentManager 执行(跟同文件里未测的 company-scoped 版本同一个覆盖范围)。 + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import express from 'express'; +import http from 'http'; + +process.env.OPEN_API_KEYS = 'test-open-api-key'; + +let app, server, baseUrl; + +function request(method, urlPath, body) { + return new Promise((resolve, reject) => { + const opts = { + method, + hostname: '127.0.0.1', + port: server.address().port, + path: urlPath, + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-open-api-key' }, + }; + const req = http.request(opts, (res) => { + let data = ''; + res.on('data', (d) => (data += d)); + res.on('end', () => { + try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } + catch { resolve({ status: res.statusCode, data }); } + }); + }); + req.on('error', reject); + if (body) req.write(JSON.stringify(body)); + req.end(); + }); +} + +describe('POST /api/open/agents/:agentId/invoke', () => { + beforeAll(async () => { + const openApiRouter = require('../../routes/open-api.js'); + app = express(); + app.use(express.json()); + app.use('/api/open', openApiRouter); + await new Promise((resolve) => { + server = http.createServer(app).listen(0, resolve); + }); + baseUrl = `http://127.0.0.1:${server.address().port}`; + }); + + afterAll(() => new Promise((resolve) => server.close(resolve))); + + it('缺 task 字段 → 400', async () => { + const res = await request('POST', '/api/open/agents/master/invoke', {}); + expect(res.status).toBe(400); + expect(res.data.error).toBe('Bad Request'); + }); + + it('agentId 带 companyId: 前缀 → 400(提示改走 company-scoped 端点)', async () => { + const res = await request('POST', '/api/open/agents/acme:coo/invoke', { task: 'hi' }); + expect(res.status).toBe(400); + expect(res.data.message).toMatch(/global agents only/); + }); + + it('本体不存在 → 404', async () => { + const res = await request('POST', '/api/open/agents/does-not-exist-xyz/invoke', { task: 'hi' }); + expect(res.status).toBe(404); + }); + + // 202 成功路径不在这里测:请求一进来就会同步触发 _runGlobalAgentInvocation 起真实 + // claude CLI 子进程(跟未测的 company-scoped 版本同一套执行路径),不适合放单测。 +}); -- libgit2 0.21.4