UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

244 lines (243 loc) • 14.3 kB
import { Type } from "typebox"; import { defineTool } from "../tools.js"; import { sanitizeUntrustedText, SHELLED_BODY_ENVELOPE_TAGS } from "../untrusted-text.js"; import { SKILL_LISTING_DEFAULT_BUDGET, SKILL_LISTING_DESC_MAX_CHARS_DEFAULT } from "../skill-listing-budget.js"; import { skillNamedBy } from "../skill-grant.js"; import { toolFace } from "../tool-catalog-entries.js"; import { substituteSkillRoot, substituteSkillRootBounded } from "./skill-base-dir.js"; import { foldListingWithin } from "./listing-fold.js"; export const OUTPUT_TOOL_NAME = "StructuredOutput"; export const REPORT_BLOCKED_TOOL_NAME = "ReportBlocked"; export const REPORT_FINDINGS_TOOL_NAME = "ReportFindings"; export const OUTPUT_DEFAULT_RETRIES = 2; export function resolveOutputRetries(outputRetries) { if (outputRetries === undefined) return OUTPUT_DEFAULT_RETRIES; if (!Number.isFinite(outputRetries) || outputRetries < 0) return OUTPUT_DEFAULT_RETRIES; return Math.floor(outputRetries); } export function createOutputTool(ref, schema, strictSchema) { return defineTool({ name: OUTPUT_TOOL_NAME, ...toolFace("structured-output"), description: "Call this to submit your FINAL result as a JSON object matching the required schema. The arguments " + "ARE the result. Calling this ends the task. Do this once you have the answer (use other tools first if needed).", parameters: strictSchema ?? schema, execute: (args) => { ref.value = args; ref.set = true; return { content: "Output recorded. Task complete.", terminate: true }; }, }); } export function createReportBlockedTool(ref) { return defineTool({ name: REPORT_BLOCKED_TOOL_NAME, ...toolFace("report-blocked"), description: "Call this ONLY when you cannot complete the task — missing information, missing permission/credentials, or an ambiguous request you cannot resolve. Provide a concise reason that an external system or operator can act on. Calling this ends the task.", parameters: Type.Object({ reason: Type.String() }), execute: (args) => { ref.reason = String(args.reason ?? "").trim() || "blocked"; return { content: "Acknowledged. Task will end as blocked.", terminate: true }; }, }); } const REPORT_FINDING_SCHEMA = Type.Object({ file: Type.String({ description: "Repo-relative path of the file the finding is in" }), line: Type.Optional(Type.Integer({ description: "1-indexed line the finding anchors to" })), summary: Type.String({ description: "One-sentence statement of the defect" }), failure_scenario: Type.String({ description: "Concrete inputs/state → wrong output/crash" }), category: Type.Optional(Type.String({ maxLength: 40, description: 'Short kebab-case slug of the finding type, e.g. "correctness", "simplification", "efficiency", "test-coverage"', })), verdict: Type.Optional(Type.Union([Type.Literal("CONFIRMED"), Type.Literal("PLAUSIBLE")], { description: "Set when a verify pass ran; absent on inline-only reviews", })), outcome: Type.Optional(Type.Union([Type.Literal("fixed"), Type.Literal("skipped"), Type.Literal("no_change_needed")], { description: "Set ONLY when re-reporting after applying fixes: what happened to this finding", })), }); export function createReportFindingsTool() { return defineTool({ name: REPORT_FINDINGS_TOOL_NAME, ...toolFace("report-findings"), description: "Report code-review findings as a typed list so the host UI can render them. Use this only when the " + "active code-review instructions tell you to report findings with this tool; otherwise follow whatever " + "output format those instructions specify. When reporting a review's results, call it once with the " + "verified findings ranked most-severe first (empty array if nothing survived verification) and do not " + "also print the findings as text. When re-reporting after applying fixes (only if the apply " + "instructions ask for it), set `outcome` on each finding to what actually happened.", parameters: Type.Object({ level: Type.Optional(Type.Union([Type.Literal("low"), Type.Literal("medium"), Type.Literal("high"), Type.Literal("xhigh"), Type.Literal("max")], { description: "Effort level the review ran at" })), findings: Type.Array(REPORT_FINDING_SCHEMA, { maxItems: 32, description: "Verified findings, most-severe first; empty if none survived", }), }, { additionalProperties: false }), execute: (args) => { const { findings, level } = args; const count = findings.length; return { content: count === 0 ? "No findings reported." : `${count} finding${count === 1 ? "" : "s"} reported.`, details: { type: "report-findings", count, ...(level !== undefined ? { level } : {}), findings }, }; }, }); } export const SKILL_TOOL_NAME = "Skill"; export const SKILL_CONTENT_MAX_CHARS = 1024 * 1024; export const SKILL_ATTACHMENTS_MAX_CHARS = 50_000; export const SKILL_ATTACHMENT_PATH_MAX_CHARS = 512; const SKILL_FILES_LIST_MAX_CHARS = 200; function skillFilesSuffix(files) { if (!files || files.length === 0) return ""; const shown = []; let used = 0; for (const f of files) { const p = f.path.length > SKILL_FILES_LIST_MAX_CHARS ? `${f.path.slice(0, SKILL_FILES_LIST_MAX_CHARS)}…` : f.path; if (used + p.length > SKILL_FILES_LIST_MAX_CHARS) break; used += p.length; shown.push(p); } const more = files.length - shown.length; if (shown.length === 0) return ` [files: ${files.length} attachment${files.length === 1 ? "" : "s"}]`; return ` [files: ${shown.join(", ")}${more > 0 ? `, +${more} more` : ""}]`; } export function normalizeSkills(skills) { const seen = new Set(); const out = []; for (const s of skills ?? []) { if (!seen.has(s.name)) { seen.add(s.name); const { name, description, content, allowedTools, disallowedTools, files, baseDir, ...rest } = s; void rest; out.push({ name, description, content, ...(allowedTools !== undefined ? { allowedTools: [...allowedTools] } : {}), ...(disallowedTools !== undefined ? { disallowedTools: [...disallowedTools] } : {}), ...(files !== undefined ? { files } : {}), ...(baseDir !== undefined ? { baseDir } : {}) }); } } return out.toSorted((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } export const SKILLS_LISTING_PROBE_HEADER = "The following skills are available for this task."; export function skillListingLine(s, descMaxChars = SKILL_LISTING_DESC_MAX_CHARS_DEFAULT) { const desc = s.description.length > descMaxChars ? `${s.description.slice(0, descMaxChars - 1)}…` : s.description; const safe = (t) => sanitizeUntrustedText(t, SHELLED_BODY_ENVELOPE_TAGS); return `- ${safe(s.name)}: ${safe(desc)}${safe(skillFilesSuffix(s.files))}`; } const SKILLS_BLOCK_CAP_MARKER = "(block size cap reached — lower-priority skills are listed name-only; descriptions via the tool)"; export function buildSkillsBlock(skills, budget) { return renderSkillsBlock(skills, budget)?.text; } export function renderSkillsBlock(skills, budget = SKILL_LISTING_DEFAULT_BUDGET) { if (skills.length === 0) return undefined; const headerLines = [ "<skills>", `${SKILLS_LISTING_PROBE_HEADER} A skill is a named package of instructions;`, `only its name and description are shown here. BEFORE doing work a skill covers, load its full`, `instructions by calling the \`${SKILL_TOOL_NAME}\` tool with \`skill: "<name>"\` (plus optional \`args\`).`, "", ]; return renderSkillLinesWithin(skills, { blockMaxBytes: budget.blockMaxBytes, descMaxChars: budget.descMaxChars, frame: (lines) => [...headerLines, ...lines, "</skills>"].join("\n"), }); } export function renderSkillLinesWithin(skills, opts) { return foldListingWithin(skills, { blockMaxBytes: opts.blockMaxBytes, full: (s) => skillListingLine(s, opts.descMaxChars), nameOnly: (s) => `- ${sanitizeUntrustedText(s.name, SHELLED_BODY_ENVELOPE_TAGS)}`, nameOf: (s) => s.name, rank: (s, i) => s.declaredRank ?? i, capMarker: SKILLS_BLOCK_CAP_MARKER, overflowLine: (n) => `(… +${n} more skills not listed — the ${SKILL_TOOL_NAME} tool serves them all)`, frame: opts.frame, }); } export function createSkillTool(skills, grants) { const names = skills.map((s) => s.name).join(", "); return defineTool({ name: SKILL_TOOL_NAME, ...toolFace("skill"), executionMode: "sequential", description: `Invoke a skill.\n\n` + `A skill is a packaged set of instructions the user or project has set up for a particular kind of task (deploy steps, a review checklist, a repo-specific workflow). ` + `Available skills appear in a system-reminder listing with one-line descriptions. ` + `When the task at hand is one a listed skill covers, call this tool first — the skill's instructions load into the turn for you to follow in place of your default approach. ` + `Users may also ask for one by name; that's a request to invoke it.\n\n` + `- \`skill\`: exact name from the listing. Directory-scoped skills are listed with a path prefix (\`apps/web:deploy\`); when both scoped and unscoped variants of a name exist, pick the one whose directory contains the files you're working on (most specific wins; unscoped otherwise).\n` + `- \`args\`: optional arguments to pass through.\n\n` + `Only names from the listing (or one the user explicitly asked for by name) are valid — never guess or invent a skill name. ` + `Do not invoke a skill that is already running. ` + `If a \`<command-name>\` block is already present this turn, the skill is loaded — follow it directly rather than calling again.`, parameters: Type.Object({ skill: Type.String({ description: 'The skill name. E.g., "commit", "review-pr", or "pdf"' }), args: Type.Optional(Type.String({ description: "Optional arguments for the skill" })), }), execute: (rawArgs) => { const a = (rawArgs ?? {}); const name = String(a.skill ?? ""); const skill = skillNamedBy(a, skills); if (!skill) { throw new Error(`Unknown skill "${name}". Available skills: ${names}. ` + `(Only skills registered with this deployment are loadable — other products' skills directories are not read. ` + `If the user wants one of those here, read its source file and recreate its content for this deployment instead.)`); } grants?.activate(skill, "model"); const baseDir = skill.baseDir; let body = baseDir !== undefined ? substituteSkillRoot(skill.content, baseDir) : skill.content; if (skill.files !== undefined && skill.files.length > 0) { const SEPARATOR_CHARS = 2; const SECTION_HEADER = "## Attachments"; const sections = [SECTION_HEADER]; const OMISSION_LINE_RESERVE = 160; let budget = SKILL_ATTACHMENTS_MAX_CHARS - SECTION_HEADER.length - OMISSION_LINE_RESERVE; let omittedCount = 0; let omittedChars = 0; for (const f of skill.files) { const path = f.path.length > SKILL_ATTACHMENT_PATH_MAX_CHARS ? `${f.path.slice(0, SKILL_ATTACHMENT_PATH_MAX_CHARS - 1)}…` : f.path; const header = `### ${path}\n`; const renderedOverhead = header.length + SEPARATOR_CHARS; const expanded = baseDir !== undefined ? substituteSkillRootBounded(f.content, baseDir, Math.max(0, budget)) : { text: f.content, totalChars: f.content.length }; const content = expanded.text; const contentChars = expanded.totalChars; if (budget <= renderedOverhead) { omittedCount += 1; omittedChars += header.length + contentChars; continue; } const contentBudget = budget - renderedOverhead; if (contentChars > contentBudget) { const markerFor = (shown) => `\n[attachment truncated: showing the first ${shown} of ${contentChars} chars — attachments cap reached]`; const shown = contentBudget - markerFor(contentBudget).length; if (shown <= 0) { omittedCount += 1; omittedChars += header.length + contentChars; budget = 0; continue; } sections.push(`${header}${content.slice(0, shown)}${markerFor(shown)}`); budget = 0; continue; } sections.push(`${header}${content}`); budget -= renderedOverhead + contentChars; } if (omittedCount > 0) { sections.push(`[+${omittedCount} more attachment(s) omitted (${omittedChars} chars) — the ${SKILL_ATTACHMENTS_MAX_CHARS}-char attachments cap is exhausted]`); } body = `${body}\n\n${sections.join("\n\n")}`; } const skillArgs = typeof a.args === "string" && a.args.length > 0 ? a.args.slice(0, 10_000) : undefined; const delivered = skillArgs ? `${body}\n\n## Input\n${skillArgs}` : body; return baseDir !== undefined ? `Base directory for this skill: ${baseDir}\n\n${delivered}` : delivered; }, }); }