UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

160 lines (156 loc) • 9.32 kB
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 } : {}), }; }); }