diff --git a/CHANGELOG.md b/CHANGELOG.md index d7990a793..4d5aad0e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Small built-in tools are defined with `defineTool` (LOU-D24, not breaking): `currentDateTool`, `dayNameTool`, `createEmailTool()`, `createHttpTool()`, `createSlackTool()`, `createDelegateTool()` and the descriptors `loadMcpTools()` builds no longer call `ai`'s `tool()`. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays), and the built-ins have the tool names `current_date`, `day_name`, `send_email`, `http_request`, `slack_alert` and `delegate_to_`. Descriptions, schemas, approval and sandbox behaviour and the factory signatures are unchanged. MCP descriptors are built with the new internal `toolDescriptorFromSchema()`, which (unlike `defineTool`) accepts any server-provided name or empty description. `routeFetchThroughSandbox()` reads the canonical `execute` first. +- GitHub and Jira tools are defined with `defineTool` (LOU-D25, not breaking): the 28 `createGitHubTools()` tools and the 20 `createJiraTools()` tools no longer call `ai`'s `tool()`, so nothing under `src/tools` imports `ai` any more. They now carry canonical `inputSchema` / `execute` (the legacy `.tool` stays). Tool names, descriptions, schemas, the sandbox flags and the factory signatures are unchanged. The GitHub out-of-scope tools (branch/file writes, code search, issue CRUD, merge) are disabled through the canonical `execute` as well as `tool.execute`, so calling one still throws "out of scope" before any HTTP request. `JiraTools.register()` now also routes tools registered with `defineTool` through the sandbox seam. - BREAKING (types only): `Message.content` is widened from `string` to `string | ContentPart[]` (LOU-V11). Messages you build keep working unchanged, but code that reads `message.content` as a string (`.length`, `.startsWith()`, a template literal) no longer type-checks. **Migration:** read `textOf(message)` instead, or narrow with `typeof message.content === 'string'`. A custom `LLMProvider` that forwards `content` should handle the parts or send `textOf(message)`. - The execute context is our own type (LOU-D23, not breaking): the new exported `ToolExecutionContext` (`{ toolCallId, messages: readonly Message[], abortSignal?, sessionId?, onDelegatedUsage? }`, `Message` from `@loushy/build-ai-agent`) replaces the `ai` SDK's `ToolExecutionOptions` as the type of the second argument of `defineTool()`'s `execute`, `ToolDescriptor.execute`, `DefinedTool.execute` and the third argument of `sandboxExecute`, and is what `buildToolRunContext()` returns; the public types no longer import `ToolExecutionOptions` from `ai`. A function typed for `ai`'s options that only reads `toolCallId` or `abortSignal` stays assignable. `ToolRunContext` is now a deprecated alias of the optional fields of `ToolExecutionContext` (it already existed with `onDelegatedUsage` and `toolCallId`). `AgentExecutionOptions.messages` is typed `Message[]` instead of the `ai` SDK's `CoreMessage[]` (the field is unused by the SDK). The legacy `ToolDescriptor.tool` (`ai` v4 `Tool`) is unchanged. See docs/tools.md#execute-context. - BREAKING (small): `SDKError.code` values are now the `LOUSHY_*` codes (LOU-D2): `AGENT_EXECUTION_ERROR` -> `LOUSHY_AGENT_EXECUTION_FAILED`, `TOOL_EXECUTION_ERROR` -> `LOUSHY_TOOL_EXECUTION_FAILED`, `LLM_PROVIDER_ERROR` -> `LOUSHY_PROVIDER_REQUEST_FAILED`, `SESSION_AWAITING_APPROVAL` -> `LOUSHY_SESSION_AWAITING_APPROVAL`, `FLOW_EXECUTION_ERROR` -> `LOUSHY_FLOW_EXECUTION_FAILED`, `CONFIGURATION_ERROR` -> `LOUSHY_CONFIG_INVALID`, `VALIDATION_ERROR` -> `LOUSHY_VALIDATION_FAILED`, `TIMEOUT_ERROR` -> `LOUSHY_OPERATION_TIMEOUT`, `RATE_LIMIT_ERROR` -> `LOUSHY_PROVIDER_RATE_LIMITED`; `code` is no longer optional (`new SDKError(message)` gets `LOUSHY_GENERIC_ERROR`). No class was renamed. The messages of `SDKError`s other than `ToolExecutionError`, `LLMProviderError`, `TimeoutError` and `RateLimitError` gain a second line with the code, hint and docs link, and `loadSpec()` now rejects a spec whose top-level field is a likely typo of a spec field (`LOUSHY_SPEC_UNKNOWN_FIELD`; other unknown fields are still ignored). **Migration:** compare `code` against the new values (or use `instanceof`); match messages with `toContain` / a regex, or compare `error.detail`, instead of exact equality. diff --git a/src/tools/built-in/github.test.ts b/src/tools/built-in/github.test.ts index cd078be26..eaef87031 100644 --- a/src/tools/built-in/github.test.ts +++ b/src/tools/built-in/github.test.ts @@ -3,6 +3,8 @@ import { GitHubTools, createGitHubTools } from './github'; import { NoopSandbox } from '../../security/sandboxCore'; import type { SandboxAdapter } from '../../security/sandboxCore'; import { executeToolWithSandboxGuard } from '../../execution/sandboxGuard'; +import type { ToolExecutionContext } from '../../types'; +import { getToolExecute, getToolInputSchema } from '../toolContract'; describe('GitHubTools scope enforcement (LOU-E14)', () => { let githubTools: GitHubTools; @@ -48,6 +50,26 @@ describe('GitHubTools scope enforcement (LOU-E14)', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + it.each(outOfScopeTools)('%s is disabled through the canonical execute (getToolExecute)', async (toolName) => { + const descriptor = githubTools.get(toolName)!; + const execute = getToolExecute(descriptor); + const ctx = {} as ToolExecutionContext; + expect(execute).toBeDefined(); + expect(descriptor.execute).toBeDefined(); + + await expect(execute!({}, ctx)).rejects.toThrow(/out of scope/i); + await expect(descriptor.execute!({}, ctx)).rejects.toThrow(/out of scope/i); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('every tool, in scope or not, exposes a canonical inputSchema and execute', () => { + for (const name of githubTools.list()) { + const descriptor = githubTools.get(name)!; + expect(getToolInputSchema(descriptor), `${name} inputSchema`).toBeDefined(); + expect(getToolExecute(descriptor), `${name} execute`).toBeTypeOf('function'); + } + }); + it('there is no repo-delete (or any repo-deletion) tool present on the registry at all', () => { const allNames = githubTools.list(); const deletionLike = allNames.filter( diff --git a/src/tools/built-in/github.ts b/src/tools/built-in/github.ts index d94dad2ef..54d93ec59 100644 --- a/src/tools/built-in/github.ts +++ b/src/tools/built-in/github.ts @@ -57,10 +57,9 @@ * repository. */ -import { tool } from 'ai'; import { z } from 'zod'; import { ToolRegistry } from '../ToolRegistry'; -import type { DefinedTool } from '../defineTool'; +import { defineTool, type DefinedTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; import { routeFetchThroughSandbox } from './sandboxFetch'; import { assertOk } from './assertOk'; @@ -361,29 +360,33 @@ export class GitHubTools extends ToolRegistry { * registerX() method below still calls `this.register(...)` exactly as * before; this override applies uniformly without touching their * individual implementations. + * + * The disabled tool is a separate descriptor whose canonical `execute` + * (the one the runtime calls, see getToolExecute()) and legacy + * `tool.execute` both throw; the real implementation is never exposed. */ public register(tool: DefinedTool): void; public register(name: string, descriptor: ToolDescriptor): void; public register(nameOrTool: string | DefinedTool, maybeDescriptor?: ToolDescriptor): void { - if (typeof nameOrTool !== 'string') { - super.register(nameOrTool); - return; - } - const name = nameOrTool; - const descriptor = maybeDescriptor as ToolDescriptor; + const name = typeof nameOrTool === 'string' ? nameOrTool : nameOrTool.name; + const descriptor = typeof nameOrTool === 'string' ? (maybeDescriptor as ToolDescriptor) : nameOrTool; if (GitHubTools.OUT_OF_SCOPE_TOOLS.has(name) && descriptor.tool) { - descriptor.tool.execute = (async () => { + const disabled = async () => { throw new Error( `GitHub tool '${name}' is out of scope for a PR-creation/reading-scoped token ` + `(requires broader permissions than "Pull requests: write" + "Issues: write" + ` + `"Contents: read" - see the scope documentation at the top of github.ts) and has ` + `been disabled.` ); - }) as typeof descriptor.tool.execute; + }; // Out-of-scope tools above throw before ever making an HTTP request, // so there's no real outbound call for a sandbox boundary to be // meaningful on - they're deliberately left unflagged below. - super.register(name, descriptor); + super.register(name, { + ...descriptor, + execute: disabled, + tool: { ...descriptor.tool, execute: disabled as ToolDescriptor['tool']['execute'] }, + }); return; } @@ -450,11 +453,12 @@ export class GitHubTools extends ToolRegistry { // ======================================================================== private registerGetRepository() { - this.register('github_get_repository', { - displayName: 'Get GitHub Repository', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_repository', + displayName: 'Get GitHub Repository', description: 'Get information about the GitHub repository', - parameters: z.object({}), + input: z.object({}), execute: async () => { const response = await fetch(this.baseUrl, { headers: { @@ -477,16 +481,17 @@ export class GitHubTools extends ToolRegistry { openIssuesCount: data.open_issues_count, }, null, 2); }, - }), - }); + }) + ); } private registerListBranches() { - this.register('github_list_branches', { - displayName: 'List GitHub Branches', - tool: tool({ + this.register( + defineTool({ + name: 'github_list_branches', + displayName: 'List GitHub Branches', description: 'List all branches in the repository', - parameters: z.object({ + input: z.object({ protected: z.boolean().optional().describe('Filter by protected status'), perPage: z.number().optional().default(30).describe('Results per page'), page: z.number().optional().default(1).describe('Page number'), @@ -515,16 +520,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ branches }, null, 2); }, - }), - }); + }) + ); } private registerGetBranch() { - this.register('github_get_branch', { - displayName: 'Get GitHub Branch', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_branch', + displayName: 'Get GitHub Branch', description: 'Get information about a specific branch', - parameters: z.object({ + input: z.object({ branchName: z.string().describe('Branch name'), }), execute: async ({ branchName }) => { @@ -548,16 +554,17 @@ export class GitHubTools extends ToolRegistry { commitMessage: data.commit.commit.message, }, null, 2); }, - }), - }); + }) + ); } private registerCreateBranch() { - this.register('github_create_branch', { - displayName: 'Create GitHub Branch', - tool: tool({ + this.register( + defineTool({ + name: 'github_create_branch', + displayName: 'Create GitHub Branch', description: 'Create a new branch in the repository', - parameters: z.object({ + input: z.object({ branchName: z.string().describe('Name for the new branch'), fromBranch: z.string().optional().describe('Source branch (default: repository default branch)'), }), @@ -605,16 +612,17 @@ export class GitHubTools extends ToolRegistry { branchName, }, null, 2); }, - }), - }); + }) + ); } private registerDeleteBranch() { - this.register('github_delete_branch', { - displayName: 'Delete GitHub Branch', - tool: tool({ + this.register( + defineTool({ + name: 'github_delete_branch', + displayName: 'Delete GitHub Branch', description: 'Delete a branch from the repository', - parameters: z.object({ + input: z.object({ branchName: z.string().describe('Branch name to delete'), }), execute: async ({ branchName }) => { @@ -633,8 +641,8 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ success: true, branchName }); }, - }), - }); + }) + ); } // ======================================================================== @@ -642,11 +650,12 @@ export class GitHubTools extends ToolRegistry { // ======================================================================== private registerListFiles() { - this.register('github_list_files', { - displayName: 'List GitHub Files', - tool: tool({ + this.register( + defineTool({ + name: 'github_list_files', + displayName: 'List GitHub Files', description: 'List files and directories at a given path in the repository', - parameters: z.object({ + input: z.object({ path: z.string().default('').describe('Directory path (empty for root)'), ref: z.string().optional().describe('Branch, tag, or commit SHA'), }), @@ -678,16 +687,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ files: result }, null, 2); }, - }), - }); + }) + ); } private registerGetFile() { - this.register('github_get_file', { - displayName: 'Get GitHub File Content', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_file', + displayName: 'Get GitHub File Content', description: 'Get the content of a specific file from the repository', - parameters: z.object({ + input: z.object({ path: z.string().describe('File path in repository'), ref: z.string().optional().describe('Branch, tag, or commit SHA'), }), @@ -716,16 +726,17 @@ export class GitHubTools extends ToolRegistry { sha: data.sha, }, null, 2); }, - }), - }); + }) + ); } private registerCreateOrUpdateFile() { - this.register('github_create_or_update_file', { - displayName: 'Create or Update GitHub File', - tool: tool({ + this.register( + defineTool({ + name: 'github_create_or_update_file', + displayName: 'Create or Update GitHub File', description: 'Create a new file or update an existing file in the repository', - parameters: z.object({ + input: z.object({ path: z.string().describe('File path in repository'), content: z.string().describe('File content'), message: z.string().describe('Commit message'), @@ -766,16 +777,17 @@ export class GitHubTools extends ToolRegistry { commitSha: data.commit.sha, }, null, 2); }, - }), - }); + }) + ); } private registerDeleteFile() { - this.register('github_delete_file', { - displayName: 'Delete GitHub File', - tool: tool({ + this.register( + defineTool({ + name: 'github_delete_file', + displayName: 'Delete GitHub File', description: 'Delete a file from the repository', - parameters: z.object({ + input: z.object({ path: z.string().describe('File path to delete'), message: z.string().describe('Commit message'), branch: z.string().describe('Branch name'), @@ -803,16 +815,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ success: true, path }); }, - }), - }); + }) + ); } private registerSearchCode() { - this.register('github_search_code', { - displayName: 'Search GitHub Code', - tool: tool({ + this.register( + defineTool({ + name: 'github_search_code', + displayName: 'Search GitHub Code', description: 'Search for code in the repository', - parameters: z.object({ + input: z.object({ query: z.string().describe('Search query'), extension: z.string().optional().describe('File extension filter (e.g., "ts", "js")'), path: z.string().optional().describe('Path filter'), @@ -854,8 +867,8 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify(result, null, 2); }, - }), - }); + }) + ); } // ======================================================================== @@ -863,11 +876,12 @@ export class GitHubTools extends ToolRegistry { // ======================================================================== private registerListPullRequests() { - this.register('github_list_pull_requests', { - displayName: 'List GitHub Pull Requests', - tool: tool({ + this.register( + defineTool({ + name: 'github_list_pull_requests', + displayName: 'List GitHub Pull Requests', description: 'List pull requests in the repository', - parameters: z.object({ + input: z.object({ state: z.enum(['open', 'closed', 'all']).default('open').describe('PR state'), sort: z.enum(['created', 'updated', 'popularity', 'long-running']).optional().describe('Sort by'), direction: z.enum(['asc', 'desc']).optional().describe('Sort direction'), @@ -905,16 +919,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ pullRequests: prs }, null, 2); }, - }), - }); + }) + ); } private registerGetPullRequest() { - this.register('github_get_pull_request', { - displayName: 'Get GitHub Pull Request', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_pull_request', + displayName: 'Get GitHub Pull Request', description: 'Get detailed information about a specific pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), }), execute: async ({ prNumber }) => { @@ -944,16 +959,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify(pr, null, 2); }, - }), - }); + }) + ); } private registerCreatePullRequest() { - this.register('github_create_pull_request', { - displayName: 'Create GitHub Pull Request', - tool: tool({ + this.register( + defineTool({ + name: 'github_create_pull_request', + displayName: 'Create GitHub Pull Request', description: 'Create a new pull request', - parameters: z.object({ + input: z.object({ title: z.string().describe('PR title'), body: z.string().describe('PR description/body'), head: z.string().describe('Source branch name'), @@ -991,16 +1007,17 @@ export class GitHubTools extends ToolRegistry { state: data.state, }, null, 2); }, - }), - }); + }) + ); } private registerUpdatePullRequest() { - this.register('github_update_pull_request', { - displayName: 'Update GitHub Pull Request', - tool: tool({ + this.register( + defineTool({ + name: 'github_update_pull_request', + displayName: 'Update GitHub Pull Request', description: 'Update an existing pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), title: z.string().optional().describe('New title'), body: z.string().optional().describe('New body'), @@ -1038,16 +1055,17 @@ export class GitHubTools extends ToolRegistry { url: data.html_url, }); }, - }), - }); + }) + ); } private registerMergePullRequest() { - this.register('github_merge_pull_request', { - displayName: 'Merge GitHub Pull Request', - tool: tool({ + this.register( + defineTool({ + name: 'github_merge_pull_request', + displayName: 'Merge GitHub Pull Request', description: 'Merge a pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), commitTitle: z.string().optional().describe('Title for merge commit'), commitMessage: z.string().optional().describe('Message for merge commit'), @@ -1083,16 +1101,17 @@ export class GitHubTools extends ToolRegistry { message: data.message, }); }, - }), - }); + }) + ); } private registerClosePullRequest() { - this.register('github_close_pull_request', { - displayName: 'Close GitHub Pull Request', - tool: tool({ + this.register( + defineTool({ + name: 'github_close_pull_request', + displayName: 'Close GitHub Pull Request', description: 'Close a pull request without merging', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), }), execute: async ({ prNumber }) => { @@ -1113,16 +1132,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ success: true, prNumber, state: 'closed' }); }, - }), - }); + }) + ); } private registerGetPRFiles() { - this.register('github_get_pr_files', { - displayName: 'Get Pull Request Files', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_pr_files', + displayName: 'Get Pull Request Files', description: 'Get the list of files changed in a pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), }), execute: async ({ prNumber }) => { @@ -1151,16 +1171,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ files }, null, 2); }, - }), - }); + }) + ); } private registerGetPRComments() { - this.register('github_get_pr_comments', { - displayName: 'Get Pull Request Comments', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_pr_comments', + displayName: 'Get Pull Request Comments', description: 'Get comments on a pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), }), execute: async ({ prNumber }) => { @@ -1189,16 +1210,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ comments }, null, 2); }, - }), - }); + }) + ); } private registerAddPRComment() { - this.register('github_add_pr_comment', { - displayName: 'Add Pull Request Comment', - tool: tool({ + this.register( + defineTool({ + name: 'github_add_pr_comment', + displayName: 'Add Pull Request Comment', description: 'Add a comment to a pull request', - parameters: z.object({ + input: z.object({ prNumber: z.number().describe('Pull request number'), body: z.string().describe('Comment body'), }), @@ -1224,8 +1246,8 @@ export class GitHubTools extends ToolRegistry { url: data.html_url, }); }, - }), - }); + }) + ); } // ======================================================================== @@ -1233,11 +1255,12 @@ export class GitHubTools extends ToolRegistry { // ======================================================================== private registerListIssues() { - this.register('github_list_issues', { - displayName: 'List GitHub Issues', - tool: tool({ + this.register( + defineTool({ + name: 'github_list_issues', + displayName: 'List GitHub Issues', description: 'List issues in the repository', - parameters: z.object({ + input: z.object({ state: z.enum(['open', 'closed', 'all']).default('open').describe('Issue state'), labels: z.array(z.string()).optional().describe('Filter by labels'), assignee: z.string().optional().describe('Filter by assignee'), @@ -1281,16 +1304,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ issues }, null, 2); }, - }), - }); + }) + ); } private registerGetIssue() { - this.register('github_get_issue', { - displayName: 'Get GitHub Issue', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_issue', + displayName: 'Get GitHub Issue', description: 'Get detailed information about a specific issue', - parameters: z.object({ + input: z.object({ issueNumber: z.number().describe('Issue number'), }), execute: async ({ issueNumber }) => { @@ -1320,16 +1344,17 @@ export class GitHubTools extends ToolRegistry { updatedAt: data.updated_at, }, null, 2); }, - }), - }); + }) + ); } private registerCreateIssue() { - this.register('github_create_issue', { - displayName: 'Create GitHub Issue', - tool: tool({ + this.register( + defineTool({ + name: 'github_create_issue', + displayName: 'Create GitHub Issue', description: 'Create a new issue in the repository', - parameters: z.object({ + input: z.object({ title: z.string().describe('Issue title'), body: z.string().optional().describe('Issue body/description'), assignees: z.array(z.string()).optional().describe('Assignee usernames'), @@ -1363,16 +1388,17 @@ export class GitHubTools extends ToolRegistry { url: data.html_url, }); }, - }), - }); + }) + ); } private registerUpdateIssue() { - this.register('github_update_issue', { - displayName: 'Update GitHub Issue', - tool: tool({ + this.register( + defineTool({ + name: 'github_update_issue', + displayName: 'Update GitHub Issue', description: 'Update an existing issue', - parameters: z.object({ + input: z.object({ issueNumber: z.number().describe('Issue number'), title: z.string().optional().describe('New title'), body: z.string().optional().describe('New body'), @@ -1407,16 +1433,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ success: true, issueNumber }); }, - }), - }); + }) + ); } private registerCloseIssue() { - this.register('github_close_issue', { - displayName: 'Close GitHub Issue', - tool: tool({ + this.register( + defineTool({ + name: 'github_close_issue', + displayName: 'Close GitHub Issue', description: 'Close an issue', - parameters: z.object({ + input: z.object({ issueNumber: z.number().describe('Issue number'), }), execute: async ({ issueNumber }) => { @@ -1437,16 +1464,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ success: true, issueNumber, state: 'closed' }); }, - }), - }); + }) + ); } private registerAddIssueComment() { - this.register('github_add_issue_comment', { - displayName: 'Add GitHub Issue Comment', - tool: tool({ + this.register( + defineTool({ + name: 'github_add_issue_comment', + displayName: 'Add GitHub Issue Comment', description: 'Add a comment to an issue', - parameters: z.object({ + input: z.object({ issueNumber: z.number().describe('Issue number'), body: z.string().describe('Comment body'), }), @@ -1472,8 +1500,8 @@ export class GitHubTools extends ToolRegistry { url: data.html_url, }); }, - }), - }); + }) + ); } // ======================================================================== @@ -1481,11 +1509,12 @@ export class GitHubTools extends ToolRegistry { // ======================================================================== private registerListCommits() { - this.register('github_list_commits', { - displayName: 'List GitHub Commits', - tool: tool({ + this.register( + defineTool({ + name: 'github_list_commits', + displayName: 'List GitHub Commits', description: 'List commits in the repository', - parameters: z.object({ + input: z.object({ sha: z.string().optional().describe('Branch or commit SHA to start from'), path: z.string().optional().describe('Only commits containing this file path'), perPage: z.number().optional().default(30).describe('Results per page'), @@ -1518,16 +1547,17 @@ export class GitHubTools extends ToolRegistry { return JSON.stringify({ commits }, null, 2); }, - }), - }); + }) + ); } private registerGetCommit() { - this.register('github_get_commit', { - displayName: 'Get GitHub Commit', - tool: tool({ + this.register( + defineTool({ + name: 'github_get_commit', + displayName: 'Get GitHub Commit', description: 'Get detailed information about a specific commit', - parameters: z.object({ + input: z.object({ sha: z.string().describe('Commit SHA'), }), execute: async ({ sha }) => { @@ -1558,16 +1588,17 @@ export class GitHubTools extends ToolRegistry { })), }, null, 2); }, - }), - }); + }) + ); } private registerCompareCommits() { - this.register('github_compare_commits', { - displayName: 'Compare GitHub Commits', - tool: tool({ + this.register( + defineTool({ + name: 'github_compare_commits', + displayName: 'Compare GitHub Commits', description: 'Compare two commits and see the differences', - parameters: z.object({ + input: z.object({ base: z.string().describe('Base commit SHA or branch'), head: z.string().describe('Head commit SHA or branch'), }), @@ -1598,8 +1629,8 @@ export class GitHubTools extends ToolRegistry { })), }, null, 2); }, - }), - }); + }) + ); } // ======================================================================== diff --git a/src/tools/built-in/jira.test.ts b/src/tools/built-in/jira.test.ts index 8a754d326..7e7302b61 100644 --- a/src/tools/built-in/jira.test.ts +++ b/src/tools/built-in/jira.test.ts @@ -5,6 +5,7 @@ import { createJiraTools } from './jira'; import { NoopSandbox } from '../../security/sandboxCore'; import type { SandboxAdapter } from '../../security/sandboxCore'; import { executeToolWithSandboxGuard } from '../../execution/sandboxGuard'; +import { getToolExecute, getToolInputSchema } from '../toolContract'; describe('JiraTools sandbox seam (LOU-K2)', () => { afterEach(() => { @@ -25,6 +26,21 @@ describe('JiraTools sandbox seam (LOU-K2)', () => { } }); + it('every tool exposes a canonical inputSchema and execute', () => { + const jiraTools = createJiraTools({ + baseUrl: 'https://jira.test', + email: 'bot@example.com', + apiToken: 'token', + }); + + expect(jiraTools.list()).toHaveLength(20); + for (const name of jiraTools.list()) { + const descriptor = jiraTools.get(name)!; + expect(getToolInputSchema(descriptor), `${name} inputSchema`).toBeDefined(); + expect(getToolExecute(descriptor), `${name} execute`).toBeTypeOf('function'); + } + }); + it('execute() is left unchanged and still makes a real request directly (unsandboxed callers)', async () => { const jiraTools = createJiraTools({ baseUrl: 'https://jira.test', diff --git a/src/tools/built-in/jira.ts b/src/tools/built-in/jira.ts index 7f91b7630..e920c3c5d 100644 --- a/src/tools/built-in/jira.ts +++ b/src/tools/built-in/jira.ts @@ -18,10 +18,9 @@ * ``` */ -import { tool } from 'ai'; import { z } from 'zod'; import { ToolRegistry } from '../ToolRegistry'; -import type { DefinedTool } from '../defineTool'; +import { defineTool, type DefinedTool } from '../defineTool'; import { ToolDescriptor } from '../../types'; import { routeFetchThroughSandbox } from './sandboxFetch'; import { assertOk } from './assertOk'; @@ -109,11 +108,11 @@ export class JiraTools extends ToolRegistry { public register(tool: DefinedTool): void; public register(name: string, descriptor: ToolDescriptor): void; public register(...args: [DefinedTool] | [string, ToolDescriptor]): void { + routeFetchThroughSandbox(args.length === 1 ? args[0] : args[1]); if (args.length === 1) { super.register(args[0]); return; } - routeFetchThroughSandbox(args[1]); super.register(args[0], args[1]); } @@ -145,11 +144,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerGetTicket() { - this.register('jira_get_ticket', { - displayName: 'Get Jira Ticket', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_ticket', + displayName: 'Get Jira Ticket', description: 'Get detailed information about a Jira ticket including all fields, comments, and metadata', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('The Jira ticket key (e.g., PROJ-123)'), expand: z.array(z.enum(['renderedFields', 'names', 'schema', 'transitions', 'operations', 'editmeta', 'changelog', 'versionedRepresentations'])) .optional() @@ -174,16 +174,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify(ticket, null, 2); }, - }), - }); + }) + ); } private registerSearchTickets() { - this.register('jira_search_tickets', { - displayName: 'Search Jira Tickets', - tool: tool({ + this.register( + defineTool({ + name: 'jira_search_tickets', + displayName: 'Search Jira Tickets', description: 'Search for Jira tickets using JQL (Jira Query Language). Returns matching tickets.', - parameters: z.object({ + input: z.object({ jql: z.string().describe('JQL query string (e.g., "project = PROJ AND status = Open")'), maxResults: z.number().optional().default(50).describe('Maximum number of results to return'), startAt: z.number().optional().default(0).describe('Starting index for pagination'), @@ -222,16 +223,17 @@ export class JiraTools extends ToolRegistry { issues: tickets, }, null, 2); }, - }), - }); + }) + ); } private registerCreateTicket() { - this.register('jira_create_ticket', { - displayName: 'Create Jira Ticket', - tool: tool({ + this.register( + defineTool({ + name: 'jira_create_ticket', + displayName: 'Create Jira Ticket', description: 'Create a new Jira ticket/issue', - parameters: z.object({ + input: z.object({ projectKey: z.string().describe('Project key (e.g., PROJ)'), issueType: z.string().describe('Issue type (e.g., Task, Bug, Story)'), summary: z.string().describe('Issue summary/title'), @@ -276,16 +278,17 @@ export class JiraTools extends ToolRegistry { self: data.self, }, null, 2); }, - }), - }); + }) + ); } private registerUpdateTicket() { - this.register('jira_update_ticket', { - displayName: 'Update Jira Ticket', - tool: tool({ + this.register( + defineTool({ + name: 'jira_update_ticket', + displayName: 'Update Jira Ticket', description: 'Update fields of an existing Jira ticket', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key to update'), summary: z.string().optional().describe('New summary'), description: z.string().optional().describe('New description'), @@ -321,16 +324,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, ticketKey }, null, 2); }, - }), - }); + }) + ); } private registerDeleteTicket() { - this.register('jira_delete_ticket', { - displayName: 'Delete Jira Ticket', - tool: tool({ + this.register( + defineTool({ + name: 'jira_delete_ticket', + displayName: 'Delete Jira Ticket', description: 'Delete a Jira ticket permanently', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key to delete'), }), execute: async ({ ticketKey }) => { @@ -349,16 +353,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, ticketKey, message: 'Ticket deleted successfully' }); }, - }), - }); + }) + ); } private registerCreateSubtask() { - this.register('jira_create_subtask', { - displayName: 'Create Jira Subtask', - tool: tool({ + this.register( + defineTool({ + name: 'jira_create_subtask', + displayName: 'Create Jira Subtask', description: 'Create a subtask under a parent Jira ticket', - parameters: z.object({ + input: z.object({ parentKey: z.string().describe('Parent ticket key'), summary: z.string().describe('Subtask summary'), description: z.string().describe('Detailed description'), @@ -396,16 +401,17 @@ export class JiraTools extends ToolRegistry { const data = await response.json(); return JSON.stringify({ key: data.key, id: data.id }, null, 2); }, - }), - }); + }) + ); } private registerLinkIssues() { - this.register('jira_link_issues', { - displayName: 'Link Jira Issues', - tool: tool({ + this.register( + defineTool({ + name: 'jira_link_issues', + displayName: 'Link Jira Issues', description: 'Create a link between two Jira issues', - parameters: z.object({ + input: z.object({ inwardIssue: z.string().describe('Inward issue key'), outwardIssue: z.string().describe('Outward issue key'), linkType: z.string().describe('Link type (e.g., "Blocks", "Relates to", "Duplicates")'), @@ -444,8 +450,8 @@ export class JiraTools extends ToolRegistry { linkType, }); }, - }), - }); + }) + ); } // ======================================================================== @@ -453,11 +459,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerAddComment() { - this.register('jira_add_comment', { - displayName: 'Add Jira Comment', - tool: tool({ + this.register( + defineTool({ + name: 'jira_add_comment', + displayName: 'Add Jira Comment', description: 'Add a comment to a Jira ticket', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), comment: z.string().describe('Comment text'), }), @@ -486,16 +493,17 @@ export class JiraTools extends ToolRegistry { author: data.author.displayName, }); }, - }), - }); + }) + ); } private registerGetComments() { - this.register('jira_get_comments', { - displayName: 'Get Jira Comments', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_comments', + displayName: 'Get Jira Comments', description: 'Get all comments for a Jira ticket', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), maxResults: z.number().optional().default(50).describe('Maximum number of comments to return'), startAt: z.number().optional().default(0).describe('Starting index for pagination'), @@ -530,16 +538,17 @@ export class JiraTools extends ToolRegistry { comments, }, null, 2); }, - }), - }); + }) + ); } private registerUpdateComment() { - this.register('jira_update_comment', { - displayName: 'Update Jira Comment', - tool: tool({ + this.register( + defineTool({ + name: 'jira_update_comment', + displayName: 'Update Jira Comment', description: 'Update an existing comment on a Jira ticket', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), commentId: z.string().describe('Comment ID to update'), newComment: z.string().describe('New comment text'), @@ -568,16 +577,17 @@ export class JiraTools extends ToolRegistry { updated: data.updated, }); }, - }), - }); + }) + ); } private registerDeleteComment() { - this.register('jira_delete_comment', { - displayName: 'Delete Jira Comment', - tool: tool({ + this.register( + defineTool({ + name: 'jira_delete_comment', + displayName: 'Delete Jira Comment', description: 'Delete a comment from a Jira ticket', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), commentId: z.string().describe('Comment ID to delete'), }), @@ -597,8 +607,8 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, commentId }); }, - }), - }); + }) + ); } // ======================================================================== @@ -606,11 +616,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerTransitionIssue() { - this.register('jira_transition_issue', { - displayName: 'Transition Jira Issue', - tool: tool({ + this.register( + defineTool({ + name: 'jira_transition_issue', + displayName: 'Transition Jira Issue', description: 'Transition a Jira issue to a different status (e.g., In Progress, Done)', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), transitionId: z.string().describe('Transition ID (get from jira_get_transitions)'), comment: z.string().optional().describe('Optional comment for the transition'), @@ -645,16 +656,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, ticketKey, transitionId }); }, - }), - }); + }) + ); } private registerGetTransitions() { - this.register('jira_get_transitions', { - displayName: 'Get Jira Transitions', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_transitions', + displayName: 'Get Jira Transitions', description: 'Get available transitions for a Jira issue (what statuses it can move to)', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), }), execute: async ({ ticketKey }) => { @@ -683,8 +695,8 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ transitions }, null, 2); }, - }), - }); + }) + ); } // ======================================================================== @@ -692,11 +704,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerAssignIssue() { - this.register('jira_assign_issue', { - displayName: 'Assign Jira Issue', - tool: tool({ + this.register( + defineTool({ + name: 'jira_assign_issue', + displayName: 'Assign Jira Issue', description: 'Assign a Jira issue to a user', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), accountId: z.string().describe('User account ID (use null or "-1" to unassign)'), }), @@ -722,16 +735,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, ticketKey, assignee: accountId }); }, - }), - }); + }) + ); } private registerAddWatcher() { - this.register('jira_add_watcher', { - displayName: 'Add Jira Watcher', - tool: tool({ + this.register( + defineTool({ + name: 'jira_add_watcher', + displayName: 'Add Jira Watcher', description: 'Add a watcher to a Jira issue', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), accountId: z.string().describe('User account ID to add as watcher'), }), @@ -753,8 +767,8 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ success: true, ticketKey, watcher: accountId }); }, - }), - }); + }) + ); } // ======================================================================== @@ -762,11 +776,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerGetIssueTypes() { - this.register('jira_get_issue_types', { - displayName: 'Get Jira Issue Types', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_issue_types', + displayName: 'Get Jira Issue Types', description: 'Get all available issue types in Jira (e.g., Task, Bug, Story, Sub-task)', - parameters: z.object({ + input: z.object({ projectKey: z.string().optional().describe('Optional project key to filter issue types'), }), execute: async ({ projectKey }) => { @@ -797,16 +812,17 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ issueTypes: types }, null, 2); }, - }), - }); + }) + ); } private registerGetProjects() { - this.register('jira_get_projects', { - displayName: 'Get Jira Projects', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_projects', + displayName: 'Get Jira Projects', description: 'Get all accessible Jira projects', - parameters: z.object({ + input: z.object({ maxResults: z.number().optional().default(50).describe('Maximum number of projects to return'), }), execute: async ({ maxResults = 50 }) => { @@ -833,8 +849,8 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ projects }, null, 2); }, - }), - }); + }) + ); } // ======================================================================== @@ -842,11 +858,12 @@ export class JiraTools extends ToolRegistry { // ======================================================================== private registerAddAttachment() { - this.register('jira_add_attachment', { - displayName: 'Add Jira Attachment', - tool: tool({ + this.register( + defineTool({ + name: 'jira_add_attachment', + displayName: 'Add Jira Attachment', description: 'Add an attachment to a Jira issue', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), fileName: z.string().describe('Name of the file'), fileContent: z.string().describe('Base64 encoded file content'), @@ -879,16 +896,17 @@ export class JiraTools extends ToolRegistry { size: data[0].size, }); }, - }), - }); + }) + ); } private registerAddWorklog() { - this.register('jira_add_worklog', { - displayName: 'Add Jira Worklog', - tool: tool({ + this.register( + defineTool({ + name: 'jira_add_worklog', + displayName: 'Add Jira Worklog', description: 'Log time spent on a Jira issue', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), timeSpent: z.string().describe('Time spent (e.g., "3h 30m", "1d", "2w")'), comment: z.string().optional().describe('Optional worklog comment'), @@ -929,16 +947,17 @@ export class JiraTools extends ToolRegistry { started: data.started, }); }, - }), - }); + }) + ); } private registerGetWorklogs() { - this.register('jira_get_worklogs', { - displayName: 'Get Jira Worklogs', - tool: tool({ + this.register( + defineTool({ + name: 'jira_get_worklogs', + displayName: 'Get Jira Worklogs', description: 'Get all worklogs for a Jira issue', - parameters: z.object({ + input: z.object({ ticketKey: z.string().describe('Ticket key'), }), execute: async ({ ticketKey }) => { @@ -966,8 +985,8 @@ export class JiraTools extends ToolRegistry { return JSON.stringify({ worklogs }, null, 2); }, - }), - }); + }) + ); } // ======================================================================== diff --git a/src/tools/noAiToolImport.test.ts b/src/tools/noAiToolImport.test.ts index 0c05b43df..ecead4d1a 100644 --- a/src/tools/noAiToolImport.test.ts +++ b/src/tools/noAiToolImport.test.ts @@ -1,5 +1,5 @@ /** - * Static check (LOU-D24): the built-in tools below are built with + * Static check (LOU-D24, LOU-D25): the built-in tools below are built with * `defineTool` (or `toolDescriptorFromSchema`) and must not import from `ai`. */ import { describe, it, expect } from 'vitest'; @@ -12,7 +12,9 @@ const CONVERTED_FILES = [ 'tools/built-in/currentDate.ts', 'tools/built-in/dayName.ts', 'tools/built-in/email.ts', + 'tools/built-in/github.ts', 'tools/built-in/http.ts', + 'tools/built-in/jira.ts', 'tools/built-in/slack.ts', 'tools/built-in/sandboxFetch.ts', 'tools/mcp/McpToolLoader.ts',