@sema-agent/core
Version:
Stateless, task-oriented AI agent core
244 lines (243 loc) • 14.3 kB
JavaScript
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;
},
});
}