Commit 1569dad4fcab6461ac28a2931c88315f1df00c44
1 parent
db92d024
feat(open-api): 加全局 agent 的外部调用入口
POST /api/open/agents/:agentId/invoke —— 复用现有 company-scoped invoke
(/api/open/companies/:companyId/agents/:agentId/invoke)那套 jobManager
异步 job + 轮询/webhook 机制,但不查 company_agents 雇佣关系(全局 agent
如 master/agent-creator/h3-video-prompt 本来就不挂在任何公司下,套雇佣
校验只会无条件 404)。只查本体是否存在(system/agents/{id}/config.json)。
两条 invoke 路由共用的执行逻辑(附件处理 + AgentManager 调用 + 写 job
结果)提到 _executeAgentInvocation,避免整段复制。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
2 changed files
with
134 additions
and
8 deletions
server/routes/open-api.js
| @@ -312,6 +312,47 @@ router.post('/companies/:companyId/agents/:agentId/invoke', h(async (req, res) = | @@ -312,6 +312,47 @@ router.post('/companies/:companyId/agents/:agentId/invoke', h(async (req, res) = | ||
| 312 | }); | 312 | }); |
| 313 | })); | 313 | })); |
| 314 | 314 | ||
| 315 | +// ── Global (System-level) Agent Invocation ── | ||
| 316 | +// 面向不挂任何公司的全局 agent(master / agent-creator / h3-video-prompt 这类系统级 | ||
| 317 | +// specialist)。company-scoped 的 /companies/:companyId/agents/:agentId/invoke 强制查 | ||
| 318 | +// company_agents「雇没雇」,全局 agent 从来不属于任何公司,套那条路一定 404。 | ||
| 319 | +// 这条不查雇佣关系,只查本体是否存在,会话落公司态目录的 `_global` 保留位 | ||
| 320 | +// (agent-locator 的既定行为,跟 heartbeat-service/agent-chat.js 里的全局 agent 是同一回事)。 | ||
| 321 | +router.post('/agents/:agentId/invoke', h(async (req, res) => { | ||
| 322 | + const { agentId } = req.params; | ||
| 323 | + const { task, contextFiles, webhookUrl } = req.body; | ||
| 324 | + | ||
| 325 | + if (!task || typeof task !== 'string') { | ||
| 326 | + return res.status(400).json({ error: 'Bad Request', message: 'The "task" field is required and must be a string.' }); | ||
| 327 | + } | ||
| 328 | + if (agentId.includes(':')) { | ||
| 329 | + 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.' }); | ||
| 330 | + } | ||
| 331 | + const locator = require('../services/agent-locator'); | ||
| 332 | + if (!fs.existsSync(path.join(locator.bodyDir(agentId), 'config.json'))) { | ||
| 333 | + return res.status(404).json({ error: `Agent "${agentId}" not found` }); | ||
| 334 | + } | ||
| 335 | + | ||
| 336 | + const job = jobManager.createJob({ | ||
| 337 | + description: `Invoke global agent ${agentId}`, | ||
| 338 | + webhookUrl, | ||
| 339 | + apiKey: req.apiKey, | ||
| 340 | + user_id: req.user?.id | ||
| 341 | + }); | ||
| 342 | + job.agentId = agentId; | ||
| 343 | + jobManager.updateJob(job.jobId, { status: 'queued', progress: 'Task is waiting for execution queue...' }); | ||
| 344 | + | ||
| 345 | + _runGlobalAgentInvocation(job, { agentId, task, contextFiles }).catch(err => { | ||
| 346 | + jobManager.updateJob(job.jobId, { status: 'failed', error: err.message, progress: `Invocation failed: ${err.message}` }); | ||
| 347 | + }); | ||
| 348 | + | ||
| 349 | + res.status(202).json({ | ||
| 350 | + jobId: job.jobId, | ||
| 351 | + status: job.status, | ||
| 352 | + message: `Agent invocation job queued. Poll GET /api/open/jobs/${job.jobId} for status.` | ||
| 353 | + }); | ||
| 354 | +})); | ||
| 355 | + | ||
| 315 | // ── Pipelines (简化版) ── | 356 | // ── Pipelines (简化版) ── |
| 316 | // 读取 .data/pipelines.json | 357 | // 读取 .data/pipelines.json |
| 317 | let pipelinesData = { pipelines: [] }; | 358 | let pipelinesData = { pipelines: [] }; |
| @@ -533,11 +574,6 @@ async function _runSkillCreation(job, resumeFromStep = null) { | @@ -533,11 +574,6 @@ async function _runSkillCreation(job, resumeFromStep = null) { | ||
| 533 | 574 | ||
| 534 | async function _runAgentInvocation(job, payload) { | 575 | async function _runAgentInvocation(job, payload) { |
| 535 | const { companyId, agentId, task, contextFiles } = payload; | 576 | const { companyId, agentId, task, contextFiles } = payload; |
| 536 | - const path = require('path'); | ||
| 537 | - const fs = require('fs'); | ||
| 538 | - const { loadConfigFromSkills } = require('../src/core/skill-config-loader'); | ||
| 539 | - const AgentManager = require('../src/core/agent-manager'); | ||
| 540 | - const config = require('../config'); | ||
| 541 | 577 | ||
| 542 | // 「这家公司有没有这个 agent」查 company_agents(28 批次 4-19)。 | 578 | // 「这家公司有没有这个 agent」查 company_agents(28 批次 4-19)。 |
| 543 | // 原来判的是公司私有目录存不存在 —— 那个目录整体没了,再判就是无条件报 404。 | 579 | // 原来判的是公司私有目录存不存在 —— 那个目录整体没了,再判就是无条件报 404。 |
| @@ -550,6 +586,27 @@ async function _runAgentInvocation(job, payload) { | @@ -550,6 +586,27 @@ async function _runAgentInvocation(job, payload) { | ||
| 550 | // 附件落公司态目录(长期,跨任务),不落任务态 —— 外部系统传进来的上下文 | 586 | // 附件落公司态目录(长期,跨任务),不落任务态 —— 外部系统传进来的上下文 |
| 551 | // 不属于某一次 run,run 目录会按保留策略清掉(28 §5.6)。 | 587 | // 不属于某一次 run,run 目录会按保留策略清掉(28 §5.6)。 |
| 552 | const agentDir = locator.companyDir(hired.system_agent_id, companyId); | 588 | const agentDir = locator.companyDir(hired.system_agent_id, companyId); |
| 589 | + const namespacedId = `${companyId}:${agentId}`; | ||
| 590 | + | ||
| 591 | + await _executeAgentInvocation(job, { agentDir, namespacedId, task, contextFiles }); | ||
| 592 | +} | ||
| 593 | + | ||
| 594 | +// 全局 agent(不挂任何公司,落 agent-locator 的 `_global` 保留位)版本 —— 无雇佣关系可查, | ||
| 595 | +// 本体存在性已经在路由层校验过。 | ||
| 596 | +async function _runGlobalAgentInvocation(job, payload) { | ||
| 597 | + const { agentId, task, contextFiles } = payload; | ||
| 598 | + const locator = require('../services/agent-locator'); | ||
| 599 | + const agentDir = locator.companyDir(agentId); // 不传 companyId 落 _global | ||
| 600 | + await _executeAgentInvocation(job, { agentDir, namespacedId: agentId, task, contextFiles }); | ||
| 601 | +} | ||
| 602 | + | ||
| 603 | +// 两条 invoke 路径共用的核心:处理附件、跑 AgentManager、写 job 结果。 | ||
| 604 | +async function _executeAgentInvocation(job, { agentDir, namespacedId, task, contextFiles }) { | ||
| 605 | + const path = require('path'); | ||
| 606 | + const fs = require('fs'); | ||
| 607 | + const { loadConfigFromSkills } = require('../src/core/skill-config-loader'); | ||
| 608 | + const AgentManager = require('../src/core/agent-manager'); | ||
| 609 | + const config = require('../config'); | ||
| 553 | 610 | ||
| 554 | jobManager.updateJob(job.jobId, { status: 'running', progress: 'Initializing agent session...' }); | 611 | jobManager.updateJob(job.jobId, { status: 'running', progress: 'Initializing agent session...' }); |
| 555 | 612 | ||
| @@ -592,9 +649,7 @@ async function _runAgentInvocation(job, payload) { | @@ -592,9 +649,7 @@ async function _runAgentInvocation(job, payload) { | ||
| 592 | 649 | ||
| 593 | const fullSystemConfig = loadConfigFromSkills(skillsDir, backends); | 650 | const fullSystemConfig = loadConfigFromSkills(skillsDir, backends); |
| 594 | const manager = new AgentManager(fullSystemConfig, rootDir); | 651 | const manager = new AgentManager(fullSystemConfig, rootDir); |
| 595 | - | ||
| 596 | - const namespacedId = `${companyId}:${agentId}`; | ||
| 597 | - | 652 | + |
| 598 | // Execute via AgentManager | 653 | // Execute via AgentManager |
| 599 | jobManager.updateJob(job.jobId, { status: 'running', progress: 'Executing agent task...' }); | 654 | jobManager.updateJob(job.jobId, { status: 'running', progress: 'Executing agent task...' }); |
| 600 | 655 | ||
| @@ -629,3 +684,4 @@ module.exports._jobManager = jobManager; | @@ -629,3 +684,4 @@ module.exports._jobManager = jobManager; | ||
| 629 | module.exports._runSkillCreation = _runSkillCreation; | 684 | module.exports._runSkillCreation = _runSkillCreation; |
| 630 | module.exports._runFeedbackIteration = _runFeedbackIteration; | 685 | module.exports._runFeedbackIteration = _runFeedbackIteration; |
| 631 | module.exports._runAgentInvocation = _runAgentInvocation; | 686 | module.exports._runAgentInvocation = _runAgentInvocation; |
| 687 | +module.exports._runGlobalAgentInvocation = _runGlobalAgentInvocation; |
server/tests/unit/open-api-global-agent-invoke.test.js
0 → 100644
| 1 | +/** | ||
| 2 | + * POST /api/open/agents/:agentId/invoke — 全局 agent 的外部调用入口(不挂公司)。 | ||
| 3 | + * 只测路由层的同步校验分支(task 必填 / 拒绝 companyId:role 形态 / 本体不存在 404), | ||
| 4 | + * 不触发真正的 AgentManager 执行(跟同文件里未测的 company-scoped 版本同一个覆盖范围)。 | ||
| 5 | + */ | ||
| 6 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | ||
| 7 | +import express from 'express'; | ||
| 8 | +import http from 'http'; | ||
| 9 | + | ||
| 10 | +process.env.OPEN_API_KEYS = 'test-open-api-key'; | ||
| 11 | + | ||
| 12 | +let app, server, baseUrl; | ||
| 13 | + | ||
| 14 | +function request(method, urlPath, body) { | ||
| 15 | + return new Promise((resolve, reject) => { | ||
| 16 | + const opts = { | ||
| 17 | + method, | ||
| 18 | + hostname: '127.0.0.1', | ||
| 19 | + port: server.address().port, | ||
| 20 | + path: urlPath, | ||
| 21 | + headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-open-api-key' }, | ||
| 22 | + }; | ||
| 23 | + const req = http.request(opts, (res) => { | ||
| 24 | + let data = ''; | ||
| 25 | + res.on('data', (d) => (data += d)); | ||
| 26 | + res.on('end', () => { | ||
| 27 | + try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } | ||
| 28 | + catch { resolve({ status: res.statusCode, data }); } | ||
| 29 | + }); | ||
| 30 | + }); | ||
| 31 | + req.on('error', reject); | ||
| 32 | + if (body) req.write(JSON.stringify(body)); | ||
| 33 | + req.end(); | ||
| 34 | + }); | ||
| 35 | +} | ||
| 36 | + | ||
| 37 | +describe('POST /api/open/agents/:agentId/invoke', () => { | ||
| 38 | + beforeAll(async () => { | ||
| 39 | + const openApiRouter = require('../../routes/open-api.js'); | ||
| 40 | + app = express(); | ||
| 41 | + app.use(express.json()); | ||
| 42 | + app.use('/api/open', openApiRouter); | ||
| 43 | + await new Promise((resolve) => { | ||
| 44 | + server = http.createServer(app).listen(0, resolve); | ||
| 45 | + }); | ||
| 46 | + baseUrl = `http://127.0.0.1:${server.address().port}`; | ||
| 47 | + }); | ||
| 48 | + | ||
| 49 | + afterAll(() => new Promise((resolve) => server.close(resolve))); | ||
| 50 | + | ||
| 51 | + it('缺 task 字段 → 400', async () => { | ||
| 52 | + const res = await request('POST', '/api/open/agents/master/invoke', {}); | ||
| 53 | + expect(res.status).toBe(400); | ||
| 54 | + expect(res.data.error).toBe('Bad Request'); | ||
| 55 | + }); | ||
| 56 | + | ||
| 57 | + it('agentId 带 companyId: 前缀 → 400(提示改走 company-scoped 端点)', async () => { | ||
| 58 | + const res = await request('POST', '/api/open/agents/acme:coo/invoke', { task: 'hi' }); | ||
| 59 | + expect(res.status).toBe(400); | ||
| 60 | + expect(res.data.message).toMatch(/global agents only/); | ||
| 61 | + }); | ||
| 62 | + | ||
| 63 | + it('本体不存在 → 404', async () => { | ||
| 64 | + const res = await request('POST', '/api/open/agents/does-not-exist-xyz/invoke', { task: 'hi' }); | ||
| 65 | + expect(res.status).toBe(404); | ||
| 66 | + }); | ||
| 67 | + | ||
| 68 | + // 202 成功路径不在这里测:请求一进来就会同步触发 _runGlobalAgentInvocation 起真实 | ||
| 69 | + // claude CLI 子进程(跟未测的 company-scoped 版本同一套执行路径),不适合放单测。 | ||
| 70 | +}); |