import { tool } from "ai"; import * as Effect from "effect/Effect"; import { z } from "zod"; import type { SkillResourceRead } from "../shared/skill-resources"; import type { SkillIdentity } from "../shared/skill-management"; import { skillName, type SkillListing } from "../shared/skills"; import { agentCall, runAgent, type AgentFailure } from "./agent-io"; import { skillCapability, skillIdentityOf } from "./skill-activity"; import type { ToolActivityDescriptor } from "./tool-activity"; export interface SkillResourceRuntime { readSkillResource(value: { skill: SkillIdentity; path: string; }): Promise; } const request = z.strictObject({ name: skillName, path: z.string().min(1).max(240), }); const unavailable = () => ({ ok: false as const, error: "Skill resource unavailable", }); const bytes = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)).byteLength; const MAX_RESULT_BYTES = 65_536; // Bound the complete model result, including JSON escaping and provenance. // Keep native source bytes intact for other SDK consumers such as script runners. export function skillResourceResult({ skill, resource }: SkillResourceRead) { const metadata = skillCapability(skill, "read_skill_resource", resource.path); const output = { ok: true as const, id: metadata.id, skill: { source: skillIdentityOf(skill).source, name: skill.name, version: skill.version, fingerprint: skill.fingerprint, }, path: resource.path, kind: resource.kind, encoding: resource.encoding ?? "text", mimeType: resource.mimeType ?? null, content: resource.content, truncated: false, }; if (bytes(output) <= MAX_RESULT_BYTES) return output; output.truncated = true; const prefix = (length: number) => { if (output.encoding === "base64") length -= length % 4; else if (length > 0 && /[\uD800-\uDBFF]/.test(resource.content[length - 1])) length--; return resource.content.slice(0, length); }; let low = 0; // The shorter JSON boolean must not make an unchanged body look truncated. let high = resource.content.length - 1; while (low < high) { const middle = Math.ceil((low + high) / 2); output.content = prefix(middle); if (bytes(output) <= MAX_RESULT_BYTES) low = middle; else high = middle - 1; } output.content = prefix(low); return output; } export class SkillResourceTools { constructor( private readonly parent: () => Effect.Effect< SkillResourceRuntime, AgentFailure >, private readonly catalog: () => SkillListing[], ) {} readonly reader = tool({ description: "Read a resource from an enabled Skill by name and relative path. Activation lists available paths. Returns bounded content with Skill version and provenance; scripts are read as text and are never executed.", inputSchema: request, execute: (value, { abortSignal }) => runAgent( Effect.gen({ self: this }, function* () { const input = request.safeParse(value); if (!input.success) return unavailable(); const skill = this.catalog().find( (item) => item.name === input.data.name, ); if (!skill) return unavailable(); const expected = skillIdentityOf(skill); const loaded = yield* this.parent().pipe( Effect.flatMap((parent) => agentCall(() => parent.readSkillResource({ skill: expected, path: input.data.path, }), ), ), Effect.timeout("15 seconds"), Effect.catch(() => Effect.succeed(null)), ); return loaded ? skillResourceResult(loaded) : unavailable(); }), abortSignal, ), }); readonly activity: ToolActivityDescriptor = { kind: "other", label: "Read Skill resource", capability: (value) => { const input = request.safeParse(value); if (!input.success) return; const skill = this.catalog().find( (item) => item.name === input.data.name, ); return skill ? skillCapability(skill, "read_skill_resource", input.data.path) : undefined; }, outcome: (output) => output && typeof output === "object" && "ok" in output && output.ok === true ? "succeeded" : "failed", outputSummary: (output) => output && typeof output === "object" && "truncated" in output && output.truncated ? "Skill resource read; content truncated" : "Skill resource read", }; }