diff --git a/.changeset/eve-tool-error-result.md b/.changeset/eve-tool-error-result.md new file mode 100644 index 0000000..5a2f07f --- /dev/null +++ b/.changeset/eve-tool-error-result.md @@ -0,0 +1,5 @@ +--- +'@github-tools/sdk': patch +--- + +Eve GitHub tool execute now returns `{ error }` on failure instead of throwing, so the model always gets a `tool_result` and the turn does not die with `MODEL_CALL_FAILED`. diff --git a/apps/docs/content/docs/2.frameworks/1.eve-extension.md b/apps/docs/content/docs/2.frameworks/1.eve-extension.md index 2d87cec..e9989b3 100644 --- a/apps/docs/content/docs/2.frameworks/1.eve-extension.md +++ b/apps/docs/content/docs/2.frameworks/1.eve-extension.md @@ -212,6 +212,8 @@ The extension registers each tool with an **authored inline** `execute` and `toM Object-shaped execute results include `rateLimit` (`remaining`, `limit`, `reset`, `resource`). `toModelOutput` strips it so the model never sees the remaining count; `toolResultFrom` and channels still do. See [Rate-limit metadata](/api/reference#rate-limit-metadata). +If execute fails (token mint, GitHub 403/429, …), the tool returns `{ error }` instead of throwing so the model always receives a `tool_result`. A thrown error in eve's tool-loop can leave a `tool_use` unpaired and kill the turn. + ## Durable approval, done right Approval **pauses the session durably** until a human responds, and policies are expressive: diff --git a/apps/docs/skills/github-tools-agents/references/eve-extension.md b/apps/docs/skills/github-tools-agents/references/eve-extension.md index e95309f..f53d208 100644 --- a/apps/docs/skills/github-tools-agents/references/eve-extension.md +++ b/apps/docs/skills/github-tools-agents/references/eve-extension.md @@ -55,7 +55,7 @@ export default githubExtension({ }) ``` -Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. That callback also strips `rateLimit` from the model-facing payload. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+. +Built-in `toModelOutput` formatters are applied via an inline callback that only closes over the tool name. That callback also strips `rateLimit` from the model-facing payload. Author `overrides.toModelOutput` inline in the agent — a library function will not get a durable descriptor on eve 0.44+. Execute failures return `{ error }` so the model still receives a `tool_result`. ## Approval diff --git a/packages/github-tools/src/eve/build.test.ts b/packages/github-tools/src/eve/build.test.ts index 11d2bb0..536e344 100644 --- a/packages/github-tools/src/eve/build.test.ts +++ b/packages/github-tools/src/eve/build.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { PRESET_TOOLS } from '../core/presets' import * as repositoryCore from '../core/repository' -import { buildEveToolDefinition, buildEveToolMap, createEveGithubToolsDynamic, formatGithubEveToolOutput, hasGithubEveToolModelOutput, listResolvedEveToolNames } from './build' +import { buildEveToolDefinition, buildEveToolMap, createEveGithubToolsDynamic, executeGithubEveTool, formatGithubEveToolOutput, hasGithubEveToolModelOutput, listResolvedEveToolNames } from './build' import { getEveTools } from './load-eve' describe('createGithubTools eve integration', () => { @@ -168,6 +168,16 @@ describe('createGithubTools eve integration', () => { }) }) + it('returns an error payload instead of throwing when execute fails', async () => { + await expect(executeGithubEveTool('getRepository', { owner: 'octocat', repo: 'hello-world' }, { + token: async () => { + throw new Error('Unable to find project root directory. Have you linked your project with `vc link?`') + }, + })).resolves.toEqual({ + error: 'Unable to find project root directory. Have you linked your project with `vc link?`', + }) + }) + it('returns stripped json for tools without a built-in formatter', () => { expect(formatGithubEveToolOutput('getRepository', { name: 'hello-world', diff --git a/packages/github-tools/src/eve/registry.ts b/packages/github-tools/src/eve/registry.ts index b3512cf..b542302 100644 --- a/packages/github-tools/src/eve/registry.ts +++ b/packages/github-tools/src/eve/registry.ts @@ -86,6 +86,13 @@ export function hasGithubEveToolModelOutput(name: GithubToolName): boolean { */ export function formatGithubEveToolOutput(name: GithubToolName, output: unknown): ToolModelOutput { const stripped = stripRateLimit(output) + // `runGithubToolStep` returns `{ error }` when execute throws. The per-tool + // formatters assume the success shape (e.g. `listPullRequestFilesToModelOutput` + // calls `.map` on the payload), so dispatching an error payload would throw a + // second time inside model-output formatting and re-break the tool loop. + if (isErrorPayload(stripped)) { + return { type: 'json', value: stripped } + } const format = GITHUB_EVE_TOOL_MODEL_OUTPUT[name as keyof typeof GITHUB_EVE_TOOL_MODEL_OUTPUT] if (!format) { return { type: 'json', value: stripped } @@ -93,6 +100,15 @@ export function formatGithubEveToolOutput(name: GithubToolName, output: unknown) return format(stripped) } +function isErrorPayload(output: unknown): output is { error: string } { + return ( + output != null + && typeof output === 'object' + && !Array.isArray(output) + && typeof (output as { error?: unknown }).error === 'string' + ) +} + export function createToolRegistry(ctx: ToolBuildContext): ToolRegistryEntry[] { const entries: ToolRegistryEntry[] = [ { diff --git a/packages/github-tools/src/eve/steps.ts b/packages/github-tools/src/eve/steps.ts index 65d5944..69f06b0 100644 --- a/packages/github-tools/src/eve/steps.ts +++ b/packages/github-tools/src/eve/steps.ts @@ -19,7 +19,13 @@ export async function runGithubToolStep( input: Record, ctx: ToolBuildContext, ) { - // Resolve the token before entering the step so only a serializable string crosses the boundary. - const token = await resolveGithubToken(ctx.token) - return executeGithubToolStep(name, input, { ...ctx, token }) + try { + // Resolve the token before entering the step so only a serializable string crosses the boundary. + const token = await resolveGithubToken(ctx.token) + return await executeGithubToolStep(name, input, { ...ctx, token }) + } catch (error) { + // Eve's tool-loop logs thrown execute errors but does not always append a + // tool_result. Returning a payload keeps the Anthropic tool_use/tool_result pairing intact. + return { error: error instanceof Error ? error.message : String(error) } + } }