UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

134 lines (133 loc) • 25.6 kB
/** * design/140 §6 1c — BUILT-IN named workflows offered by the `Workflow` tool's `{name}` calling surface, * mirroring the built-in agents precedent (src/agents/builtin-agents.ts: GA default-on, opt out via a * deployment config flag, same-name deployment registration SHADOWS the built-in): * - `RunWorkflowToolDeps.builtinWorkflows: false` (threaded from `RunnerDeps.builtinWorkflows`) removes * them wholesale; * - a deployment `WorkflowScriptStore.resolveName` hit for the SAME NAME wins (the built-in is consulted * only when the deployment registry does not resolve the name). * * The first built-in is `discussion` — the round-based collab profile design/140 §1 verified live * (docs/DESIGN-140-VERIFICATION-2026-07-11.md B 面: 2 members × 2 rounds + finalizer, 8.2s/10.5K tokens, * round 2 genuinely responding to round 1). It is pure SCRIPT CONTENT over the existing primitives — zero * new runtime mechanism: * - args schema (ALL optional — zero-config must run): `{ topic, members?: [{ role, prompt?, model? }], * rounds?, finalizer?: { prompt?, model? } }`; a bare STRING args is accepted as the topic. * - budget: reuses the workflow `budget` hard ceiling + DETERMINISTIC in-script truncation (a rounds/member * cap and a `budget.remaining()` early-stop — never an evaluator agent; design/140 §1 "预算" row). * - `meta.whenToUse` carries the REQUIRED negative boundary (when-NOT-to-use: single factual question / * budget-sensitive runs — design/140 §4 "两投影"). */ import type { NamedWorkflowListing } from "./workflow-script-store.js"; /** The built-in round-based discussion workflow's registered name. */ export declare const DISCUSSION_WORKFLOW_NAME = "discussion"; /** * The registered name this built-in carried before the C-R14 vocabulary ruling (2026-08-16): "team" now * names the agent-teams family (persistent named teammates) only, and a one-off multi-agent debate is a * "discussion". * * RETENTION, as a fact rather than a plan: the ruling's original "kept resolvable for ONE minor" window * LAPSED. The alias has stayed on the public export surface from the 5.37.0 window through 7.x, across * BOTH the 6.0.0 and 7.0.0 BREAKING windows. It is therefore retained until a removal is announced in a * named BREAKING window's ship post — no minor removes it by default. * * @deprecated Use {@link DISCUSSION_WORKFLOW_NAME}. The retired spelling still RESOLVES via * {@link canonicalWorkflowName}, but the listing/card face shows the new name only. */ export declare const TEAM_DISCUSSION_WORKFLOW_NAME = "discussion"; /** * Map a REQUESTED built-in workflow name to its canonical name — identity for every name that is not a * retired alias. The canonical name is the authority: a resolution reached through an alias returns the * canonical definition (its `meta.name`, hence every run/card projection, is the new name). * * @deprecated together with the alias table — it becomes an identity function when that table is removed * in an announced BREAKING window (C-R14). */ export declare function canonicalWorkflowName(name: string): string; /** * The retired spellings that address a given canonical built-in name (empty for every name with no * alias). Callers that probe the deployment registry per built-in name (the card's shadow probe) must * probe these too, so a deployment entry registered under EITHER spelling shadows the same built-in. * * @deprecated together with the alias table (C-R14). */ export declare function retiredWorkflowNameAliases(canonicalName: string): string[]; /** * Every spelling that addresses the SAME registry slot as `requested`, in lookup order: the requested * spelling first (so a deployment entry registered under the exact name the caller used always wins), then * its canonical name, then that canonical name's other retired spellings. Deduplicated in insertion order, * so a name with no alias yields exactly `[requested]` — one probe, unchanged from before the alias existed. * * SINGLE SOURCE on purpose. The deployment registry is consulted from two places — the `{name}` execute * path and the tool card's shadow probe — and design/140's card≡execute invariant means a SPELLING * asymmetry between them is a lie on the card, not a cosmetic difference: the first draft of this rename * probed the alias only on ONE side, so a deployment that had registered the RETIRED spelling was reported * as shadowing the built-in on the card while a canonical-name call silently launched the built-in instead * (found in adversarial review). Both sides iterate this list; neither builds its own. * * SCOPE of that guarantee, stated precisely: this aligns the two sites on WHICH SPELLINGS they ask about. * The other card/execute asymmetries are older than this helper and unchanged by it — the card swallows a * probe exception and keeps going while execute returns a structured error, the card treats any non- * `undefined` answer as a shadow while execute refuses a malformed shape, the card resolves at mount time * while execute resolves per call, and `list()` can overlay rows `resolveName` never answered. Equivalence * holds for a stable, well-formed registry; it was never total, and this helper does not make it so. * * NOTE for store implementers: a single resolution may therefore call `resolveName` more than once, with * different spellings, until one answers. `resolveName` must be a stable, side-effect-free lookup. * * Callers must apply the deployment's `builtinWorkflows` opt-out BEFORE using this: the equivalence is a * fact about the built-in registry, so a deployment that removed the built-ins removed the alias with them. * * @deprecated together with the alias table — collapses to `[requested]` when that table is removed in an * announced BREAKING window (C-R14). */ export declare function workflowNameProbeOrder(requested: string): string[]; /** * The `discussion` script source (design/140 §1 table row 1, live-verified shape: for-loop rounds + * `agent()` members with transcript re-feed + a schema'd finalizer). Deterministic by construction — no * clock/randomness reads (locked by test against `workflowScriptReadsClockOrRandom`). */ export declare const DISCUSSION_SCRIPT = "export const meta = {\n name: \"discussion\",\n 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.\",\n 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.\",\n phases: [\n { title: \"Discussion\" },\n { title: \"Synthesis\" },\n ],\n};\n// Zero-config runnable (design/140 \u00A76 1c): every arg has an opinionated fallback.\nconst raw = args;\nconst a = raw !== null && typeof raw === \"object\" && !Array.isArray(raw) ? raw : {};\nconst topic =\n typeof a.topic === \"string\" && a.topic.trim() !== \"\"\n ? a.topic\n : typeof raw === \"string\" && raw.trim() !== \"\"\n ? raw // ergonomic form: a bare string args IS the topic\n : \"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?\";\nconst defaultMembers = [\n { role: \"advocate\", prompt: \"Make the strongest constructive case. Propose concrete options and argue their benefits with specifics.\" },\n { role: \"skeptic\", prompt: \"Stress-test every claim made so far. Surface risks, hidden costs, failure modes, and cheaper alternatives.\" },\n];\nconst rawMembers = Array.isArray(a.members) && a.members.length > 0 ? a.members : defaultMembers;\nconst members = rawMembers.slice(0, 6).map((m, i) => {\n const mm = m !== null && typeof m === \"object\" ? m : {};\n const member = {\n role: typeof mm.role === \"string\" && mm.role.trim() !== \"\" ? mm.role : \"member-\" + (i + 1),\n 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.\",\n };\n if (typeof mm.model === \"string\" && mm.model.trim() !== \"\") member.model = mm.model;\n // Slot-tools carrier (design/140 \u2461-3 + F4): a member may BE a registered agent type ({agent:\"reviewer\"}) \u2014\n // persona/tools/model then come from the deployment's AgentDefinition (role library), args stay thin.\n if (typeof mm.agent === \"string\" && mm.agent.trim() !== \"\") member.agent = mm.agent;\n return member;\n});\n// Deterministic budget truncation (design/140 \u00A71 \u9884\u7B97 row): a HARD rounds ceiling + member cap \u2014 never an\n// evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.\nconst requestedRounds = Math.floor(Number(a.rounds));\nconst normalizedRounds = Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2;\nconst rounds = Math.min(normalizedRounds, 5);\n// RB-380 disclosure: the member/round slices above are silent by construction (Array.prototype.slice /\n// Math.min just drop the excess) \u2014 record + surface it instead of a caller finding out only by counting\n// transcript entries. Fires only when a request actually exceeded a ceiling (never on the common path).\nconst capNotes = [];\nif (rawMembers.length > 6) capNotes.push(\"requested \" + rawMembers.length + \" members, capped at 6\");\nif (normalizedRounds > 5) capNotes.push(\"requested \" + normalizedRounds + \" rounds, capped at 5\");\nfor (const note of capNotes) log(\"discussion: \" + note);\nconst fin = a.finalizer !== null && typeof a.finalizer === \"object\" && !Array.isArray(a.finalizer) ? a.finalizer : {};\nconst finalizerPrompt = typeof fin.prompt === \"string\" && fin.prompt.trim() !== \"\"\n ? fin.prompt\n : \"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.\";\nconst clip = (s) => { const t = String(s); return t.length > 4000 ? t.slice(0, 4000) + \" ...[truncated]\" : t; };\nconst isBudgetStop = (e) => e !== null && typeof e === \"object\" && e.code === \"workflow.budget_exceeded\";\n\nphase(\"Discussion\");\nconst transcript = [];\nlet truncated = null;\nfor (let r = 1; r <= rounds && truncated === null; r++) {\n // Deterministic early stop on an exhausted budget (a live read of the engine budget; the engine's\n // hard WorkflowBudgetExceededError remains the backstop if a member call itself crosses the line).\n if (budget.total !== null && budget.remaining() <= 0) { truncated = \"budget exhausted before round \" + r; break; }\n for (const m of members) {\n const history = transcript.length === 0 ? \"(none yet - you open the discussion)\" : transcript.join(\"\\n\\n\");\n const spec = {\n objective:\n \"Discussion on: \" + topic + \"\\n\\n\" +\n 'You are \"' + m.role + '\" in round ' + r + \" of \" + rounds + \".\\n\" +\n \"Your brief: \" + m.prompt + \"\\n\\n\" +\n \"Transcript so far:\\n\" + history + \"\\n\\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.\",\n };\n if (m.model !== undefined) spec.modelName = m.model;\n let res;\n try {\n res = await agent(spec, m.agent !== undefined ? { label: m.role + \"-r\" + r, phase: \"Discussion\", agentType: m.agent } : { label: m.role + \"-r\" + r, phase: \"Discussion\" });\n } catch (e) {\n // The engine's budget hard stop: keep what the discussion already produced instead of failing the run.\n if (isBudgetStop(e)) { truncated = \"budget exhausted at \" + m.role + \", round \" + r; break; }\n throw e;\n }\n const text = res && res.terminal && res.terminal.kind === \"completed\" ? clip(res.result) : \"(no contribution - agent ended \" + (res && res.terminal ? res.terminal.kind : \"unknown\") + \")\";\n transcript.push(m.role + \" (round \" + r + \"): \" + text);\n }\n}\n\nphase(\"Synthesis\");\nconst finalSpec = {\n objective:\n finalizerPrompt + \"\\n\\nTopic: \" + topic + \"\\n\\nFull transcript:\\n\" +\n (transcript.length === 0 ? \"(the discussion produced no contributions)\" : transcript.join(\"\\n\\n\")) +\n (truncated ? \"\\n\\nNote: the discussion was cut short (\" + truncated + \").\" : \"\"),\n};\nif (typeof fin.model === \"string\" && fin.model.trim() !== \"\") finalSpec.modelName = fin.model;\nlet verdict = null;\ntry {\n verdict = await agent(finalSpec, {\n label: \"finalizer\",\n phase: \"Synthesis\",\n schema: {\n type: \"object\",\n properties: {\n decision: { type: \"string\", description: \"The final answer/decision, one paragraph.\" },\n keyPoints: { type: \"array\", items: { type: \"string\" }, description: \"The strongest supporting points from the discussion.\" },\n dissent: { type: \"string\", description: \"The strongest unresolved counter-position, if any.\" },\n },\n required: [\"decision\", \"keyPoints\"],\n },\n });\n} catch (e) {\n // Budget died before synthesis: return the transcript honestly rather than failing the whole run.\n if (!isBudgetStop(e)) throw e;\n truncated = truncated === null ? \"budget exhausted before synthesis\" : truncated;\n}\n\nreturn {\n topic,\n rounds,\n members: members.map((m) => m.role),\n ...(capNotes.length > 0 ? { capped: capNotes } : {}),\n ...(truncated ? { truncated } : {}),\n transcript,\n verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),\n};\n"; /** * @deprecated Use {@link DISCUSSION_SCRIPT}. Retired spelling retained (C-R14; the original one-minor * window lapsed — removal needs an announced BREAKING window) so a deployment importing the constant by * its old name keeps compiling; the value is the SAME script (its `meta.name` * is the new `discussion`). */ export declare const TEAM_DISCUSSION_SCRIPT = "export const meta = {\n name: \"discussion\",\n 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.\",\n 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.\",\n phases: [\n { title: \"Discussion\" },\n { title: \"Synthesis\" },\n ],\n};\n// Zero-config runnable (design/140 \u00A76 1c): every arg has an opinionated fallback.\nconst raw = args;\nconst a = raw !== null && typeof raw === \"object\" && !Array.isArray(raw) ? raw : {};\nconst topic =\n typeof a.topic === \"string\" && a.topic.trim() !== \"\"\n ? a.topic\n : typeof raw === \"string\" && raw.trim() !== \"\"\n ? raw // ergonomic form: a bare string args IS the topic\n : \"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?\";\nconst defaultMembers = [\n { role: \"advocate\", prompt: \"Make the strongest constructive case. Propose concrete options and argue their benefits with specifics.\" },\n { role: \"skeptic\", prompt: \"Stress-test every claim made so far. Surface risks, hidden costs, failure modes, and cheaper alternatives.\" },\n];\nconst rawMembers = Array.isArray(a.members) && a.members.length > 0 ? a.members : defaultMembers;\nconst members = rawMembers.slice(0, 6).map((m, i) => {\n const mm = m !== null && typeof m === \"object\" ? m : {};\n const member = {\n role: typeof mm.role === \"string\" && mm.role.trim() !== \"\" ? mm.role : \"member-\" + (i + 1),\n 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.\",\n };\n if (typeof mm.model === \"string\" && mm.model.trim() !== \"\") member.model = mm.model;\n // Slot-tools carrier (design/140 \u2461-3 + F4): a member may BE a registered agent type ({agent:\"reviewer\"}) \u2014\n // persona/tools/model then come from the deployment's AgentDefinition (role library), args stay thin.\n if (typeof mm.agent === \"string\" && mm.agent.trim() !== \"\") member.agent = mm.agent;\n return member;\n});\n// Deterministic budget truncation (design/140 \u00A71 \u9884\u7B97 row): a HARD rounds ceiling + member cap \u2014 never an\n// evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.\nconst requestedRounds = Math.floor(Number(a.rounds));\nconst normalizedRounds = Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2;\nconst rounds = Math.min(normalizedRounds, 5);\n// RB-380 disclosure: the member/round slices above are silent by construction (Array.prototype.slice /\n// Math.min just drop the excess) \u2014 record + surface it instead of a caller finding out only by counting\n// transcript entries. Fires only when a request actually exceeded a ceiling (never on the common path).\nconst capNotes = [];\nif (rawMembers.length > 6) capNotes.push(\"requested \" + rawMembers.length + \" members, capped at 6\");\nif (normalizedRounds > 5) capNotes.push(\"requested \" + normalizedRounds + \" rounds, capped at 5\");\nfor (const note of capNotes) log(\"discussion: \" + note);\nconst fin = a.finalizer !== null && typeof a.finalizer === \"object\" && !Array.isArray(a.finalizer) ? a.finalizer : {};\nconst finalizerPrompt = typeof fin.prompt === \"string\" && fin.prompt.trim() !== \"\"\n ? fin.prompt\n : \"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.\";\nconst clip = (s) => { const t = String(s); return t.length > 4000 ? t.slice(0, 4000) + \" ...[truncated]\" : t; };\nconst isBudgetStop = (e) => e !== null && typeof e === \"object\" && e.code === \"workflow.budget_exceeded\";\n\nphase(\"Discussion\");\nconst transcript = [];\nlet truncated = null;\nfor (let r = 1; r <= rounds && truncated === null; r++) {\n // Deterministic early stop on an exhausted budget (a live read of the engine budget; the engine's\n // hard WorkflowBudgetExceededError remains the backstop if a member call itself crosses the line).\n if (budget.total !== null && budget.remaining() <= 0) { truncated = \"budget exhausted before round \" + r; break; }\n for (const m of members) {\n const history = transcript.length === 0 ? \"(none yet - you open the discussion)\" : transcript.join(\"\\n\\n\");\n const spec = {\n objective:\n \"Discussion on: \" + topic + \"\\n\\n\" +\n 'You are \"' + m.role + '\" in round ' + r + \" of \" + rounds + \".\\n\" +\n \"Your brief: \" + m.prompt + \"\\n\\n\" +\n \"Transcript so far:\\n\" + history + \"\\n\\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.\",\n };\n if (m.model !== undefined) spec.modelName = m.model;\n let res;\n try {\n res = await agent(spec, m.agent !== undefined ? { label: m.role + \"-r\" + r, phase: \"Discussion\", agentType: m.agent } : { label: m.role + \"-r\" + r, phase: \"Discussion\" });\n } catch (e) {\n // The engine's budget hard stop: keep what the discussion already produced instead of failing the run.\n if (isBudgetStop(e)) { truncated = \"budget exhausted at \" + m.role + \", round \" + r; break; }\n throw e;\n }\n const text = res && res.terminal && res.terminal.kind === \"completed\" ? clip(res.result) : \"(no contribution - agent ended \" + (res && res.terminal ? res.terminal.kind : \"unknown\") + \")\";\n transcript.push(m.role + \" (round \" + r + \"): \" + text);\n }\n}\n\nphase(\"Synthesis\");\nconst finalSpec = {\n objective:\n finalizerPrompt + \"\\n\\nTopic: \" + topic + \"\\n\\nFull transcript:\\n\" +\n (transcript.length === 0 ? \"(the discussion produced no contributions)\" : transcript.join(\"\\n\\n\")) +\n (truncated ? \"\\n\\nNote: the discussion was cut short (\" + truncated + \").\" : \"\"),\n};\nif (typeof fin.model === \"string\" && fin.model.trim() !== \"\") finalSpec.modelName = fin.model;\nlet verdict = null;\ntry {\n verdict = await agent(finalSpec, {\n label: \"finalizer\",\n phase: \"Synthesis\",\n schema: {\n type: \"object\",\n properties: {\n decision: { type: \"string\", description: \"The final answer/decision, one paragraph.\" },\n keyPoints: { type: \"array\", items: { type: \"string\" }, description: \"The strongest supporting points from the discussion.\" },\n dissent: { type: \"string\", description: \"The strongest unresolved counter-position, if any.\" },\n },\n required: [\"decision\", \"keyPoints\"],\n },\n });\n} catch (e) {\n // Budget died before synthesis: return the transcript honestly rather than failing the whole run.\n if (!isBudgetStop(e)) throw e;\n truncated = truncated === null ? \"budget exhausted before synthesis\" : truncated;\n}\n\nreturn {\n topic,\n rounds,\n members: members.map((m) => m.role),\n ...(capNotes.length > 0 ? { capped: capNotes } : {}),\n ...(truncated ? { truncated } : {}),\n transcript,\n verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),\n};\n"; /** One built-in named workflow: the registered name + its self-contained script source. The name is the * routing key of the `{name}` calling surface; the script's `meta.name` matches it (locked by test). */ export interface BuiltinWorkflowDefinition { name: string; script: string; } /** * The built-in named-workflow registry. Order = card listing order. * * RB-342 (2026-07-29): the previous note called this a "CC `HCe`-analog shape". `HCe` is not a workflow * symbol in the 220 corpus at all (it is a memoized JSX local in a renderer, pretty220 885850+) — the name * came from an older bundle and drifted, the standing hazard with minified identifiers. The real 220 * counterpart is the built-in workflow list behind `Rsn()` / `Dft()` (451216 / 454626), and the shape * matches only PARTLY: * * - **Registry, not literal.** 220's backing array is initialised EMPTY (`kSd = []`, 451221) and filled at * startup by feature-gated registrars (`DRo`, 451207 — two callers, `code-review` @496716 and * `deep-research` @497127, each behind its own enablement check); `Rsn()` additionally returns `[]` * wholesale when its gate is off. sema's list is a compile-time literal with no gating. Same role, * different lifecycle — do not read "CC ships these built-ins unconditionally" out of this function. * - **Shadowing holds; ordering does not transfer.** 220 resolves by concatenating * `[...builtins.filter(name not taken), ...userLevel, ...projectLevel]` (454641-454647): a * deployment-defined workflow of the same name REMOVES the built-in, which is the same net rule as * {@link resolveBuiltinWorkflow} being the second lookup. But 220 lists built-ins FIRST and enforces the * shadow by filtering, while sema enforces it by lookup order — so the listing order here is sema's own * choice, not a parity constraint. */ export declare function builtinWorkflowDefinitions(): BuiltinWorkflowDefinition[]; /** Resolve a built-in workflow by name (the `{name}` surface's SECOND lookup — a deployment * `scriptStore.resolveName` hit for the same name shadows this, design/140 §6 1c). A retired spelling * (C-R14, one minor) resolves through {@link canonicalWorkflowName} to the SAME definition, so the * returned `name` — and therefore every downstream projection — is the canonical one. */ export declare function resolveBuiltinWorkflow(name: string): BuiltinWorkflowDefinition | undefined; /** design/140 §6 1b — the built-ins' listing projection rows (name + description + whenToUse, parsed from * each script's static meta). Consumed by the Workflow tool card renderer. */ export declare function builtinWorkflowListings(): NamedWorkflowListing[];