@sema-agent/core
Version:
Stateless, task-oriented AI agent core
160 lines (156 loc) • 9.32 kB
JavaScript
import { parseWorkflowMeta } from "./workflow-meta.js";
export const DISCUSSION_WORKFLOW_NAME = "discussion";
export const TEAM_DISCUSSION_WORKFLOW_NAME = DISCUSSION_WORKFLOW_NAME;
const RETIRED_WORKFLOW_NAME_ALIASES = new Map([
["team-discussion", DISCUSSION_WORKFLOW_NAME],
]);
export function canonicalWorkflowName(name) {
return RETIRED_WORKFLOW_NAME_ALIASES.get(name) ?? name;
}
export function retiredWorkflowNameAliases(canonicalName) {
return [...RETIRED_WORKFLOW_NAME_ALIASES].filter(([, canonical]) => canonical === canonicalName).map(([alias]) => alias);
}
export function workflowNameProbeOrder(requested) {
const canonical = canonicalWorkflowName(requested);
const ordered = [requested, canonical, ...retiredWorkflowNameAliases(canonical)];
return [...new Set(ordered)];
}
export const DISCUSSION_SCRIPT = `export const meta = {
name: "discussion",
description: "Round-based discussion: configurable members debate a topic across rounds (each member sees the transcript so far), then a finalizer synthesizes a structured verdict.",
whenToUse: "Use for a genuinely contested question that benefits from several perspectives arguing across rounds - design trade-offs, plan or risk reviews, adversarial critique of a proposal. Do NOT use it for a single factual question, a task with one obvious answer, or a budget-sensitive run: every round costs one agent call per member, so a discussion is never cheaper than asking once. args (all optional): { topic, members?: [{ role, prompt?, model? }], rounds?, finalizer?: { prompt?, model? } }. Hard ceilings: members is capped at 6 and rounds is capped at 5 regardless of what you pass; the run reports it via log() and a capped field on the result when a request exceeds either.",
phases: [
{ title: "Discussion" },
{ title: "Synthesis" },
],
};
// Zero-config runnable (design/140 §6 1c): every arg has an opinionated fallback.
const raw = args;
const a = raw !== null && typeof raw === "object" && !Array.isArray(raw) ? raw : {};
const topic =
typeof a.topic === "string" && a.topic.trim() !== ""
? a.topic
: typeof raw === "string" && raw.trim() !== ""
? raw // ergonomic form: a bare string args IS the topic
: "No topic was provided. Discuss: what information should a caller supply to make a discussion like this productive, and when should they NOT convene one?";
const defaultMembers = [
{ role: "advocate", prompt: "Make the strongest constructive case. Propose concrete options and argue their benefits with specifics." },
{ role: "skeptic", prompt: "Stress-test every claim made so far. Surface risks, hidden costs, failure modes, and cheaper alternatives." },
];
const rawMembers = Array.isArray(a.members) && a.members.length > 0 ? a.members : defaultMembers;
const members = rawMembers.slice(0, 6).map((m, i) => {
const mm = m !== null && typeof m === "object" ? m : {};
const member = {
role: typeof mm.role === "string" && mm.role.trim() !== "" ? mm.role : "member-" + (i + 1),
prompt: typeof mm.prompt === "string" && mm.prompt.trim() !== "" ? mm.prompt : "Contribute your own distinct perspective: be concrete, give reasons, and engage with what others said.",
};
if (typeof mm.model === "string" && mm.model.trim() !== "") member.model = mm.model;
// Slot-tools carrier (design/140 ②-3 + F4): a member may BE a registered agent type ({agent:"reviewer"}) —
// persona/tools/model then come from the deployment's AgentDefinition (role library), args stay thin.
if (typeof mm.agent === "string" && mm.agent.trim() !== "") member.agent = mm.agent;
return member;
});
// Deterministic budget truncation (design/140 §1 预算 row): a HARD rounds ceiling + member cap — never an
// evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.
const requestedRounds = Math.floor(Number(a.rounds));
const normalizedRounds = Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2;
const rounds = Math.min(normalizedRounds, 5);
// RB-380 disclosure: the member/round slices above are silent by construction (Array.prototype.slice /
// Math.min just drop the excess) — record + surface it instead of a caller finding out only by counting
// transcript entries. Fires only when a request actually exceeded a ceiling (never on the common path).
const capNotes = [];
if (rawMembers.length > 6) capNotes.push("requested " + rawMembers.length + " members, capped at 6");
if (normalizedRounds > 5) capNotes.push("requested " + normalizedRounds + " rounds, capped at 5");
for (const note of capNotes) log("discussion: " + note);
const fin = a.finalizer !== null && typeof a.finalizer === "object" && !Array.isArray(a.finalizer) ? a.finalizer : {};
const finalizerPrompt = typeof fin.prompt === "string" && fin.prompt.trim() !== ""
? fin.prompt
: "You are the synthesis lead. Read the full discussion transcript and produce the final verdict: the decision/answer, the key supporting points, and the strongest unresolved dissent (if any). Do not introduce new arguments of your own.";
const clip = (s) => { const t = String(s); return t.length > 4000 ? t.slice(0, 4000) + " ...[truncated]" : t; };
const isBudgetStop = (e) => e !== null && typeof e === "object" && e.code === "workflow.budget_exceeded";
phase("Discussion");
const transcript = [];
let truncated = null;
for (let r = 1; r <= rounds && truncated === null; r++) {
// Deterministic early stop on an exhausted budget (a live read of the engine budget; the engine's
// hard WorkflowBudgetExceededError remains the backstop if a member call itself crosses the line).
if (budget.total !== null && budget.remaining() <= 0) { truncated = "budget exhausted before round " + r; break; }
for (const m of members) {
const history = transcript.length === 0 ? "(none yet - you open the discussion)" : transcript.join("\\n\\n");
const spec = {
objective:
"Discussion on: " + topic + "\\n\\n" +
'You are "' + m.role + '" in round ' + r + " of " + rounds + ".\\n" +
"Your brief: " + m.prompt + "\\n\\n" +
"Transcript so far:\\n" + history + "\\n\\n" +
"Respond to the strongest points others made (do not repeat yourself), then advance your own position. Be concise: a few tight paragraphs at most.",
};
if (m.model !== undefined) spec.modelName = m.model;
let res;
try {
res = await agent(spec, m.agent !== undefined ? { label: m.role + "-r" + r, phase: "Discussion", agentType: m.agent } : { label: m.role + "-r" + r, phase: "Discussion" });
} catch (e) {
// The engine's budget hard stop: keep what the discussion already produced instead of failing the run.
if (isBudgetStop(e)) { truncated = "budget exhausted at " + m.role + ", round " + r; break; }
throw e;
}
const text = res && res.terminal && res.terminal.kind === "completed" ? clip(res.result) : "(no contribution - agent ended " + (res && res.terminal ? res.terminal.kind : "unknown") + ")";
transcript.push(m.role + " (round " + r + "): " + text);
}
}
phase("Synthesis");
const finalSpec = {
objective:
finalizerPrompt + "\\n\\nTopic: " + topic + "\\n\\nFull transcript:\\n" +
(transcript.length === 0 ? "(the discussion produced no contributions)" : transcript.join("\\n\\n")) +
(truncated ? "\\n\\nNote: the discussion was cut short (" + truncated + ")." : ""),
};
if (typeof fin.model === "string" && fin.model.trim() !== "") finalSpec.modelName = fin.model;
let verdict = null;
try {
verdict = await agent(finalSpec, {
label: "finalizer",
phase: "Synthesis",
schema: {
type: "object",
properties: {
decision: { type: "string", description: "The final answer/decision, one paragraph." },
keyPoints: { type: "array", items: { type: "string" }, description: "The strongest supporting points from the discussion." },
dissent: { type: "string", description: "The strongest unresolved counter-position, if any." },
},
required: ["decision", "keyPoints"],
},
});
} catch (e) {
// Budget died before synthesis: return the transcript honestly rather than failing the whole run.
if (!isBudgetStop(e)) throw e;
truncated = truncated === null ? "budget exhausted before synthesis" : truncated;
}
return {
topic,
rounds,
members: members.map((m) => m.role),
...(capNotes.length > 0 ? { capped: capNotes } : {}),
...(truncated ? { truncated } : {}),
transcript,
verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),
};
`;
export const TEAM_DISCUSSION_SCRIPT = DISCUSSION_SCRIPT;
export function builtinWorkflowDefinitions() {
return [{ name: DISCUSSION_WORKFLOW_NAME, script: DISCUSSION_SCRIPT }];
}
export function resolveBuiltinWorkflow(name) {
const canonical = canonicalWorkflowName(name);
return builtinWorkflowDefinitions().find((w) => w.name === canonical);
}
export function builtinWorkflowListings() {
return builtinWorkflowDefinitions().map((w) => {
const meta = parseWorkflowMeta(w.script);
return {
name: w.name,
description: meta.description,
...(meta.whenToUse !== undefined ? { whenToUse: meta.whenToUse } : {}),
};
});
}