Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/lib/workflows/runAgentStep.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export async function runAgentStep(input: RunAgentStepInput): Promise<{ finishRe
});

const modelMessages = convertToModelMessages(input.messages);
const tools = buildAgentTools();
const tools = buildAgentTools({ skills: input.agentContext.skills });
const result = streamText({
model: gateway(input.modelId),
system: agentCustomInstructions,
Expand Down
47 changes: 39 additions & 8 deletions lib/agent/__tests__/buildAgentTools.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { buildAgentTools } from "@/lib/agent/buildAgentTools";

const EXPECTED_TOOL_NAMES = [
const BASE_TOOLS = [
"bash",
"read",
"write",
Expand All @@ -13,19 +13,50 @@ const EXPECTED_TOOL_NAMES = [
] as const;

describe("buildAgentTools", () => {
it("returns a tools record with all 8 leaf tools registered", () => {
it("returns the 8 leaf tools by default (no skill registered when skills list is empty)", () => {
const tools = buildAgentTools();
for (const name of EXPECTED_TOOL_NAMES) {
for (const name of BASE_TOOLS) {
expect(tools).toHaveProperty(name);
}
expect(tools).not.toHaveProperty("skill");
});

it("registers the skill tool when a non-empty skill catalog is provided", () => {
const tools = buildAgentTools({
skills: [
{
name: "commit",
description: "Make a commit",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
],
});
expect(tools).toHaveProperty("skill");
for (const name of BASE_TOOLS) {
expect(tools).toHaveProperty(name);
}
});

it("omits the skill tool when an empty array is passed", () => {
const tools = buildAgentTools({ skills: [] });
expect(tools).not.toHaveProperty("skill");
});

it("each tool exposes the AI SDK shape (description + inputSchema + execute)", () => {
const tools = buildAgentTools() as Record<
string,
{ description?: unknown; inputSchema?: unknown; execute?: unknown }
>;
for (const name of EXPECTED_TOOL_NAMES) {
const tools = buildAgentTools({
skills: [
{
name: "foo",
description: "x",
path: "/p",
filename: "SKILL.md",
options: {},
},
],
}) as Record<string, { description?: unknown; inputSchema?: unknown; execute?: unknown }>;
for (const name of [...BASE_TOOLS, "skill"]) {
const t = tools[name]!;
expect(typeof t.description).toBe("string");
expect(t.inputSchema).toBeDefined();
Expand Down
24 changes: 14 additions & 10 deletions lib/agent/buildAgentTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,27 @@ import { grepTool } from "@/lib/agent/tools/grepTool";
import { globTool } from "@/lib/agent/tools/globTool";
import { todoWriteTool } from "@/lib/agent/tools/todoWriteTool";
import { webFetchTool } from "@/lib/agent/tools/webFetchTool";
import { skillTool } from "@/lib/agent/tools/skillTool";
import type { SkillMetadata } from "@/lib/skills/skillTypes";

/**
* Factory for the full agent tool set passed into `streamText({ tools })`.
* Each tool reads its sandbox handle + recoup creds from `experimental_context`
* at execute time — the factory takes no arguments because the tools are
* stateless modulo that context.
* Each tool reads its sandbox handle + per-prompt context from
* `experimental_context` at execute time — the factory is otherwise stateless.
*
* Currently ships 8 leaf tools:
* - bash, read, write, edit, grep, glob (sandbox / file ops)
* Currently ships 9 tools:
* - 6 file/shell: bash, read, write, edit, grep, glob
* - todo_write (planning surface; stateless, echoes the list back)
* - web_fetch (HTTP via curl inside the sandbox)
* - skill (load a project-level skill's SKILL.md; only registered when the
* sandbox has skills available, so models without any skill catalog
* don't see the tool at all and never call it speculatively)
*
* Composite tools (`task` subagent, `ask_user_question` UI part,
* `skill` skill discovery) port in a follow-up PR — they require
* subagent context plumbing / UI rendering / skill discovery infra
* that isn't in api today.
* @param options.skills - Discovered skill catalog. When empty / undefined,
* `skill` is omitted from the tool record so the model doesn't see it.
*/
export function buildAgentTools() {
export function buildAgentTools(options: { skills?: SkillMetadata[] } = {}) {
const hasSkills = (options.skills?.length ?? 0) > 0;
return {
bash: bashTool,
read: readFileTool,
Expand All @@ -33,6 +36,7 @@ export function buildAgentTools() {
glob: globTool,
todo_write: todoWriteTool,
web_fetch: webFetchTool,
...(hasSkills ? { skill: skillTool } : {}),
};
}

Expand Down
11 changes: 11 additions & 0 deletions lib/agent/tools/AgentContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { VercelState } from "@/lib/sandbox/vercel/state";
import type { SkillMetadata } from "@/lib/skills/skillTypes";

/**
* Per-tool-call context threaded into the agent via `streamText`'s
Expand Down Expand Up @@ -31,4 +32,14 @@ export type AgentContext = {
* Public information — no security risk in exposing.
*/
recoupOrgId?: string;
/**
* Skills discovered in the sandbox before workflow start (handler
* calls `discoverSkills(sandbox, getSandboxSkillDirectories(sandbox))`).
* The `skillTool` reads this list to:
* - resolve names → SKILL.md paths
* - filter out skills with `disable-model-invocation`
* - surface "Available skills" hints when a model picks an unknown name
* Empty / undefined when the sandbox has no `skills/` directory.
*/
skills?: SkillMetadata[];
};
169 changes: 169 additions & 0 deletions lib/agent/tools/__tests__/skillTool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Custom agent: Enforce Clear Code Style and Maintainability Practices

File exceeds the 100-line limit required by the style/maintainability rule.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At lib/agent/tools/__tests__/skillTool.test.ts, line 1:

<comment>File exceeds the 100-line limit required by the style/maintainability rule.</comment>

<file context>
@@ -0,0 +1,169 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { skillTool } from "@/lib/agent/tools/skillTool";
+import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel";
</file context>

import { skillTool } from "@/lib/agent/tools/skillTool";
import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel";

vi.mock("@/lib/sandbox/vercel/connect/connectVercel", () => ({
connectVercel: vi.fn(),
}));

const baseCtx = {
sandbox: { state: { sandboxName: "x" }, workingDirectory: "/sandbox/mono" },
};

function makeSandbox(readFile: ReturnType<typeof vi.fn>) {
return { workingDirectory: "/sandbox/mono", readFile };
}

function skillMd(body: string) {
return `---\nname: commit\ndescription: Make a commit\n---\n\n${body}`;
}

beforeEach(() => vi.clearAllMocks());

describe("skillTool", () => {
it("returns success:false with available skills when the requested skill isn't in context", async () => {
vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never);
const result = (await skillTool.execute!({ skill: "unknown" }, {
experimental_context: {
...baseCtx,
skills: [
{
name: "commit",
description: "Make a commit",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
{
name: "deploy",
description: "Deploy",
path: "/sandbox/mono/skills/deploy",
filename: "SKILL.md",
options: {},
},
],
},
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/Available skills: commit, deploy/);
});

it("returns success:false when no skills are loaded", async () => {
vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never);
const result = (await skillTool.execute!({ skill: "commit" }, {
experimental_context: { ...baseCtx, skills: [] },
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/Available skills: none/);
});

it("matches the skill name case-insensitively (slash-command behavior)", async () => {
const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd("body content")));
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const result = (await skillTool.execute!(
{ skill: "COMMIT" }, // model typed it loud
{
experimental_context: {
...baseCtx,
skills: [
{
name: "commit",
description: "x",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
],
},
} as never,
)) as { success: boolean; skillName: string };
expect(result.success).toBe(true);
expect(result.skillName).toBe("COMMIT");
});

it("returns the SKILL.md body with skill directory injected", async () => {
const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd("Run git commit -m ...")));
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const result = (await skillTool.execute!({ skill: "commit" }, {
experimental_context: {
...baseCtx,
skills: [
{
name: "commit",
description: "x",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
],
},
} as never)) as { success: boolean; content: string; skillPath: string };
expect(result.success).toBe(true);
expect(result.skillPath).toBe("/sandbox/mono/skills/commit");
expect(result.content).toContain("Skill directory: /sandbox/mono/skills/commit");
expect(result.content).toContain("Run git commit -m ...");
expect(sb.readFile).toHaveBeenCalledWith("/sandbox/mono/skills/commit/SKILL.md", "utf-8");
});

it("substitutes $ARGUMENTS in the skill body when args are provided", async () => {
const sb = makeSandbox(vi.fn().mockResolvedValue(skillMd('git commit -m "$ARGUMENTS"')));
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const result = (await skillTool.execute!({ skill: "commit", args: "fix bug" }, {
experimental_context: {
...baseCtx,
skills: [
{
name: "commit",
description: "x",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
],
},
} as never)) as { content: string };
expect(result.content).toContain('git commit -m "fix bug"');
expect(result.content).not.toContain("$ARGUMENTS");
});

it("rejects skills with disable-model-invocation set", async () => {
vi.mocked(connectVercel).mockResolvedValue(makeSandbox(vi.fn()) as never);
const result = (await skillTool.execute!({ skill: "internal" }, {
experimental_context: {
...baseCtx,
skills: [
{
name: "internal",
description: "x",
path: "/sandbox/mono/skills/internal",
filename: "SKILL.md",
options: { disableModelInvocation: true },
},
],
},
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/cannot be invoked/);
});

it("returns success:false when the SKILL.md read fails", async () => {
const sb = makeSandbox(vi.fn().mockRejectedValue(new Error("ENOENT")));
vi.mocked(connectVercel).mockResolvedValue(sb as never);
const result = (await skillTool.execute!({ skill: "commit" }, {
experimental_context: {
...baseCtx,
skills: [
{
name: "commit",
description: "x",
path: "/sandbox/mono/skills/commit",
filename: "SKILL.md",
options: {},
},
],
},
} as never)) as { success: boolean; error: string };
expect(result.success).toBe(false);
expect(result.error).toMatch(/ENOENT/);
});
});
Loading
Loading