diff --git a/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md b/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md index 7871959d4..60597dd9b 100644 --- a/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md +++ b/docs/explorations/0194_[_]_EXTENSIBILITY_FABRIC_PLUGINS_LABS_AI_EDITOR.md @@ -526,12 +526,15 @@ classDiagram ### Phase 2 — AI drives the ecosystem -- [ ] Register `@xnetjs/labs/agent-tools` (`lab_run`/`lab_list`/`lab_create`/ +- [~] Register `@xnetjs/labs/agent-tools` (`lab_run`/`lab_list`/`lab_create`/ `lab_get`/`lab_run_saved`) with `mcp-server.ts` and the in-app agent runtime, - backed by an injected `LabAgentBackend`. _(deferred — the labs `LabAgentTool` - shape is already MCP-shaped; the remaining work is the cross-package adapter - (lives in labs, which already depends on plugins) + the app/electron MCP - registration.)_ + backed by an injected `LabAgentBackend`. _As-built: `labs/agent-tools-ai.ts`'s + `labAgentToolsToAiTools()` adapts the (already MCP-shaped) `LabAgentTool`s to + the `AiCallableTool` shape `@xnetjs/plugins` uses — execution tools (`lab_run` + etc.) flagged `high` risk, reads `low`, input schemas pass straight through + (`LabToolPropertySchema ⊆ AiJsonSchema`). Lives in labs (which already + depends on plugins, avoiding the cycle). The app/electron MCP-server + + agent-runtime registration of the result is the remaining wiring._ - [x] Add `contributionsAsAiTools()` + an `aiExposed`/`inputSchema` opt-in on `CommandContribution`; expose them as capability-scoped, callable `AiToolDefinition`s. _As-built: `AiCommandExposure` adds diff --git a/packages/labs/src/__tests__/agent-tools-ai.test.ts b/packages/labs/src/__tests__/agent-tools-ai.test.ts new file mode 100644 index 000000000..4e94012ca --- /dev/null +++ b/packages/labs/src/__tests__/agent-tools-ai.test.ts @@ -0,0 +1,59 @@ +/** + * Tests for adapting Lab agent tools to AI tools (0194 Phase 2). + */ + +import type { LabAgentTool } from '../agent-tools' +import { describe, it, expect, vi } from 'vitest' +import { labAgentToolsToAiTools } from '../agent-tools-ai' + +function labTool(name: string, invoke = vi.fn().mockResolvedValue('ok')): LabAgentTool { + return { + name, + description: `The ${name} tool`, + inputSchema: { type: 'object', properties: { id: { type: 'string' } } }, + invoke + } +} + +describe('labAgentToolsToAiTools', () => { + it('adapts name/title/description and passes the input schema through', () => { + const [tool] = labAgentToolsToAiTools([labTool('lab_run')]) + expect(tool.name).toBe('lab_run') + expect(tool.title).toBe('Lab run') + expect(tool.description).toBe('The lab_run tool') + expect(tool.inputSchema.properties.id).toEqual({ type: 'string' }) + expect(tool.requiredScopes).toEqual(['workspace.read']) + }) + + it('marks execution tools high risk and read tools low risk', () => { + const tools = labAgentToolsToAiTools([ + labTool('lab_run'), + labTool('lab_create'), + labTool('lab_run_saved'), + labTool('lab_get'), + labTool('lab_list') + ]) + const risk = Object.fromEntries(tools.map((t) => [t.name, t.risk])) + expect(risk).toEqual({ + lab_run: 'high', + lab_create: 'high', + lab_run_saved: 'high', + lab_get: 'low', + lab_list: 'low' + }) + }) + + it('invoke calls the underlying tool and wraps the result as text content', async () => { + const invoke = vi.fn().mockResolvedValue({ rows: 3 }) + const [tool] = labAgentToolsToAiTools([labTool('lab_run', invoke)]) + const result = await tool.invoke({ code: 'x' }) + expect(invoke).toHaveBeenCalledWith({ code: 'x' }) + expect(result.content).toEqual([{ type: 'text', text: '{"rows":3}' }]) + }) + + it('passes a string result through unchanged', async () => { + const [tool] = labAgentToolsToAiTools([labTool('lab_get', vi.fn().mockResolvedValue('hello'))]) + const result = await tool.invoke({ id: '1' }) + expect(result.content[0].text).toBe('hello') + }) +}) diff --git a/packages/labs/src/agent-tools-ai.ts b/packages/labs/src/agent-tools-ai.ts new file mode 100644 index 000000000..d2077a4a2 --- /dev/null +++ b/packages/labs/src/agent-tools-ai.ts @@ -0,0 +1,59 @@ +/** + * @xnetjs/labs — Lab agent tools as AI tools (exploration 0194 Phase 2). + * + * `createLabAgentTools` already produces MCP-shaped tools (`lab_run`/`lab_list`/ + * …) but they aren't in the workspace AI's tool surface. This adapts them to the + * `AiCallableTool` shape `@xnetjs/plugins` uses, so the agent can discover, run, + * and author Labs alongside the workspace tools — the AI→Lab→Plugin loop's first + * hop. It lives in labs because labs already depends on `@xnetjs/plugins` (the + * reverse would cycle). + * + * `LabToolPropertySchema` is structurally a subset of `AiJsonSchema`, so the + * input schema passes straight through. Execution tools (`lab_run`/`lab_create`/ + * `lab_run_saved`) are marked `high` risk so the agent/consent layer can gate + * them; read tools (`lab_get`/`lab_list`) are `low`. + */ + +import type { LabAgentTool } from './agent-tools' +import type { AiCallableTool } from '@xnetjs/plugins' + +/** Lab tools that execute code (vs. read metadata) — surfaced as higher risk. */ +const EXECUTION_TOOLS = new Set(['lab_run', 'lab_create', 'lab_run_saved']) + +/** `lab_run` → `Lab run`. */ +function titleize(name: string): string { + return name.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase()) +} + +/** Stringify a tool result for the text content block. */ +function resultText(value: unknown): string { + if (typeof value === 'string') return value + try { + return JSON.stringify(value) + } catch { + return String(value) + } +} + +function toAiTool(tool: LabAgentTool): AiCallableTool { + return { + name: tool.name, + title: titleize(tool.name), + description: tool.description, + risk: EXECUTION_TOOLS.has(tool.name) ? 'high' : 'low', + requiredScopes: ['workspace.read'], + inputSchema: tool.inputSchema, + invoke: async (args) => ({ + content: [{ type: 'text', text: resultText(await tool.invoke(args)) }] + }) + } +} + +/** + * Adapt Lab agent tools to the `AiCallableTool` shape so the workspace AI can + * call them. Pair with `createLabAgentTools(...)` and register the result with + * the MCP server / in-app agent runtime. + */ +export function labAgentToolsToAiTools(tools: readonly LabAgentTool[]): AiCallableTool[] { + return tools.map(toAiTool) +} diff --git a/packages/labs/src/index.ts b/packages/labs/src/index.ts index c3f312003..56aafe2a4 100644 --- a/packages/labs/src/index.ts +++ b/packages/labs/src/index.ts @@ -82,6 +82,9 @@ export type { LabToolPropertySchema } from './agent-tools' +// Lab agent tools as AI tools (0194 Phase 2) — for the MCP server / agent runtime. +export { labAgentToolsToAiTools } from './agent-tools-ai' + // Extension publishing export { buildLabExtensionManifest, publishLabAsExtension, slugifyForId } from './extension' export type {