Commit 1569dad4fcab6461ac28a2931c88315f1df00c44

Authored by yibo
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>
server/routes/open-api.js
... ... @@ -312,6 +312,47 @@ router.post(&#39;/companies/:companyId/agents/:agentId/invoke&#39;, 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 356 // ── Pipelines (简化版) ──
316 357 // 读取 .data/pipelines.json
317 358 let pipelinesData = { pipelines: [] };
... ... @@ -533,11 +574,6 @@ async function _runSkillCreation(job, resumeFromStep = null) {
533 574  
534 575 async function _runAgentInvocation(job, payload) {
535 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 578 // 「这家公司有没有这个 agent」查 company_agents(28 批次 4-19)。
543 579 // 原来判的是公司私有目录存不存在 —— 那个目录整体没了,再判就是无条件报 404。
... ... @@ -550,6 +586,27 @@ async function _runAgentInvocation(job, payload) {
550 586 // 附件落公司态目录(长期,跨任务),不落任务态 —— 外部系统传进来的上下文
551 587 // 不属于某一次 run,run 目录会按保留策略清掉(28 §5.6)。
552 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 611 jobManager.updateJob(job.jobId, { status: 'running', progress: 'Initializing agent session...' });
555 612  
... ... @@ -592,9 +649,7 @@ async function _runAgentInvocation(job, payload) {
592 649  
593 650 const fullSystemConfig = loadConfigFromSkills(skillsDir, backends);
594 651 const manager = new AgentManager(fullSystemConfig, rootDir);
595   -
596   - const namespacedId = `${companyId}:${agentId}`;
597   -
  652 +
598 653 // Execute via AgentManager
599 654 jobManager.updateJob(job.jobId, { status: 'running', progress: 'Executing agent task...' });
600 655  
... ... @@ -629,3 +684,4 @@ module.exports._jobManager = jobManager;
629 684 module.exports._runSkillCreation = _runSkillCreation;
630 685 module.exports._runFeedbackIteration = _runFeedbackIteration;
631 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 +});
... ...