MVP底座⑤:自定义工具导入路由 —— 与第一方同门两审
顺手补两处底座:试运行审此前传空连接,靠 endpoint 干活的工具一律过不了门(这道门 只对不需要连接的工具有效);现传本次申报的连接值。未注册第一方处理器的工具走通用 HTTP 实现 —— 导入的工具由此可真调、可真审:端点不通就是不通,不入目录。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -62,6 +62,7 @@ import type { TriggerService } from '@modules/trigger';
|
||||
import type { MinuteService } from '@modules/minute';
|
||||
import type { ReportService } from '@modules/report';
|
||||
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
|
||||
import { httpToolHandler } from './tools/http-tool.ts';
|
||||
import { wireEngines } from './wiring/engines.ts';
|
||||
import { wireDeskElements } from './wiring/desk-elements.ts';
|
||||
import type { ChannelService } from '@modules/channel';
|
||||
@@ -262,7 +263,8 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
});
|
||||
|
||||
const toolRegistry = createToolRegistry(db);
|
||||
const toolRuntime = createToolRuntime(firstPartyToolHandlers);
|
||||
// 导入的自定义工具走通用 HTTP 实现:契约申报 endpoint,运行时只把那一个连接目标交出去
|
||||
const toolRuntime = createToolRuntime(firstPartyToolHandlers, httpToolHandler);
|
||||
const schemas = createSchemaValidator();
|
||||
const toolbox = createToolboxService({
|
||||
registry: toolRegistry,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
publishProcessRoute,
|
||||
registerToolRoute,
|
||||
saveProcessRoute,
|
||||
reassignTaskRoute,
|
||||
resumeTaskRoute,
|
||||
@@ -63,6 +64,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const governTrigger = guard('trigger', 'govern');
|
||||
const readActivity = guard('activity', 'read');
|
||||
const readTool = guard('tool', 'read');
|
||||
const governTool = guard('tool', 'govern');
|
||||
const readData = guard('data', 'read');
|
||||
const readMemory = guard('memory', 'read');
|
||||
|
||||
@@ -376,6 +378,32 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
|
||||
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 导入自定义工具 —— 与第一方走同一条门:静态审 + 试运行审,过了才入目录。
|
||||
* 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。
|
||||
*/
|
||||
app.post(registerToolRoute.path, async (c) => {
|
||||
const denied = await governTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = registerToolRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const { contract, samples, connectionValues } = parsed.data;
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
// 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说
|
||||
samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })),
|
||||
...(connectionValues === undefined ? {} : { connectionValues }),
|
||||
});
|
||||
return c.json({
|
||||
accepted: verdict.accepted,
|
||||
findings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })),
|
||||
trialRun: { ran: verdict.trialRun.ran, passed: verdict.trialRun.passed, failures: [...verdict.trialRun.failures] },
|
||||
});
|
||||
});
|
||||
|
||||
app.get(listToolsRoute.path, async (c) => {
|
||||
const denied = await readTool(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* 导入工具的通用实现 —— 「实现对系统」的兜底一件(编码规范 §3;语义出处 OS 7.3)。
|
||||
*
|
||||
* 第一方工具各有各的处理器;**导入进来的自定义工具**没有处理器,
|
||||
* 但它在契约里申报了 `endpoint` 连接键 —— 这个处理器就把入参 POST 到那个端点。
|
||||
*
|
||||
* 运行时受限照旧:只拿得到申报的连接目标,没有别的环境能力。
|
||||
* 端点不通 = 试运行审不过 = 进不了目录 —— 这是**门**,不是障碍:
|
||||
* 一件调不通的工具入了目录,活动调它时才失败,那才是真的坏。
|
||||
*/
|
||||
import type { ToolHandler } from '@modules/toolbox';
|
||||
|
||||
/** 未在第一方注册的工具:申报了 endpoint 就按 HTTP 调,没申报就如实说没有实现 */
|
||||
export const httpToolHandler: ToolHandler = async ({ input, connection, operation, idempotencyKey }) => {
|
||||
const endpoint = connection['endpoint'];
|
||||
if (endpoint === undefined || endpoint.trim() === '') {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'tool-unimplemented',
|
||||
retryable: false,
|
||||
message: '工具无实现:未申报 endpoint 连接键,系统侧也没有第一方处理器',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!/^https?:\/\//.test(endpoint)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'endpoint-invalid', retryable: false, message: `端点须是 http(s) 地址:${endpoint}` },
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// 幂等键透传给对端:写类动作重投时由对端去重(契约声明 idempotent 才有意义)
|
||||
...(idempotencyKey === undefined ? {} : { 'idempotency-key': idempotencyKey }),
|
||||
},
|
||||
body: JSON.stringify({ input, ...(operation === undefined ? {} : { operation }) }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: `http-${response.status}`,
|
||||
// 5xx 可重试,4xx 是我们自己请求得不对,重试没用
|
||||
retryable: response.status >= 500,
|
||||
message: `端点返回 ${response.status}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const body: unknown = await response.json();
|
||||
if (typeof body !== 'object' || body === null || Array.isArray(body)) {
|
||||
return { ok: false, error: { code: 'output-invalid', retryable: false, message: '端点返回的不是 JSON 对象' } };
|
||||
}
|
||||
return { ok: true, output: body as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'endpoint-unreachable',
|
||||
retryable: true,
|
||||
message: `端点调用失败:${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user