@sema-agent/core
Version:
Stateless, task-oriented AI agent core
134 lines (133 loc) • 25.6 kB
TypeScript
/**
* 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[];