@sema-agent/core
Version:
Stateless, task-oriented AI agent core
227 lines (226 loc) • 12.4 kB
TypeScript
import type { RunnerSelfSeat } from "../core/runner/contracts.js";
import type { McpServerSpec, ModelRef, ModelRole, TaskSpec, ToolSpec } from "../core/types.js";
import type { CheckpointGate, CheckpointToken } from "../core/checkpoint-store.js";
export interface TeamMember {
/** The member's role / specialty, e.g. "安全评审" or "performance". */
role: string;
/** Optional per-member model (falls back to the team default). */
model?: ModelRef;
/** Optional per-member model **role** (when `model` is omitted) — for per-member cost tiering, e.g.
* a "researcher" on `"subagent"` (cheap) and a "critic" on `"default"`. Default `"team"`. */
modelRole?: ModelRole;
/** Optional persona / standing instructions for this member. */
systemPrompt?: string;
/**
* Tools this member may use mid-debate to **ground or verify** a claim against the real artifacts
* (e.g. a read-only repo tool) instead of speculating — tool-augmented debate (the "Tool-MAD"
* pattern). Falls back to the team's shared {@link TeamDiscussionOptions.tools}. A member's turn
* becomes a full agent loop, so it is bounded by `limits` (and core's default turn cap).
*/
tools?: ToolSpec[];
/**
* Per-member tool allow/deny (design/38 1A), applied to this member's resolved tools (own or the
* team default): `allowTools` keeps only the named tools (`["*"]` = all), then `denyTools` removes.
* Lets a "researcher" be read-only and an "implementer" carry write tools without separate roles.
*/
allowTools?: string[];
denyTools?: string[];
/** Task-scoped MCP servers for this member; falls back to the team's shared `mcp`. */
mcp?: McpServerSpec[];
}
export interface TeamTurn {
round: number;
role: string;
text: string;
/** True when this member turn failed (retries exhausted). Surfaced in the result for visibility,
* but **excluded from every prompt** so an `[unavailable]` marker can't pollute the discussion. */
failed?: boolean;
}
export type TeamEvent = {
type: "member_start";
round: number;
role: string;
} | {
type: "member_end";
round: number;
role: string;
text: string;
}
/** budget axes: the cumulative team budget was exhausted after this member's turn settled —
* remaining rounds/members are skipped and the discussion goes straight to synthesis. */
| {
type: "budget_stop";
axis: "maxTokens" | "maxCostUsd";
round: number;
role: string;
} | {
type: "synthesis_start";
} | {
type: "done";
};
export interface TeamDiscussionOptions {
runner: RunnerSelfSeat;
/** Default model for members / synthesizer without their own. If omitted, members resolve the
* `team` role and the synthesizer the `synthesize` role (both fall back to `default`). */
model?: ModelRef;
/** The participants. */
members: TeamMember[];
/** What the team is discussing / deciding. */
topic: string;
/** Number of discussion rounds before synthesis. Default 2. */
rounds?: number;
/**
* Cap on the shared transcript embedded in each prompt. When the running transcript exceeds
* this many estimated tokens, the oldest statements are summarized into a running summary
* (keeping prompts bounded over many rounds). Omit to disable.
*/
maxTranscriptTokens?: number;
/**
* Default tools for members that don't declare their own — e.g. a read-only repo tool so the
* debate can **verify claims against the real artifacts** rather than argue over unseen code.
* Members without tools simply reason over the shared transcript (the convergence layer).
*/
tools?: ToolSpec[];
/** Default MCP servers for members without their own. */
mcp?: McpServerSpec[];
/**
* Who synthesizes the conclusion (default: a neutral facilitator on the default model). The
* synthesizer may carry its own `tools`/`mcp` to spot-check a disputed point before deciding; by
* default it has none (it converges over the transcript, it doesn't re-explore).
*/
synthesizer?: {
role?: string;
model?: ModelRef;
modelRole?: ModelRole;
systemPrompt?: string;
/** Tools specific to the synthesizer; does NOT fall back to the team-level `opts.tools`. */
tools?: ToolSpec[];
/** Per-agent tool allow/deny (design/38 1A), applied to the synthesizer's `tools`. */
allowTools?: string[];
denyTools?: string[];
mcp?: McpServerSpec[];
};
/**
* Per-model auth — MIRRORS {@link TaskSpec.getApiKeyAndHeaders} (forwarded into every member /
* summary / synthesizer run, which resolve it against their own resolved models). Without this
* seat a team whose member/synthesizer models live on per-model-credential routes had NO way to
* carry the resolver: every nested run fell to the brain's construction-time credential, which the
* key↔URL pairing gate refuses off the deployment root — the teacher/verify delegation family
* carries the same seat for the same reason.
*/
getApiKeyAndHeaders?: TaskSpec["getApiKeyAndHeaders"];
/** Progress callback. */
onEvent?: (e: TeamEvent) => void;
/**
* `maxWalltimeMs`/`maxTurns` are PER-RUN caps forwarded to every member/summary/synthesizer run.
*
* `maxTokens`/`maxCostUsd` (CollabTemplate.budget mid-flight enforcement) are CUMULATIVE
* team budgets over member + summary + synthesizer spend (nested/delegated spend included, same
* coordinate as the `stats` totals). Enforcement is checked after each member turn settles and is
* booked — the crossing member is never killed in flight — and once a budget is exhausted
* (running total ≥ budget) no further member turn or round is dispatched. The transcript produced
* so far still goes through the NORMAL synthesis close-out (money already spent must yield a
* conclusion), so the synthesizer always runs; its spend is booked but not gated. Attribution
* lands in {@link TeamResult.budgetStop} plus a `budget_stop` event. `maxCostUsd` is compared in
* integer micro-USD (the engine's `stats.costMicroUsd` coordinate — no float equality); runs that
* report no cost (no `model.cost`/pricing) book 0 toward it. Both optional — omitted ⇒ prior
* behavior byte-for-byte. The budget axes are NOT forwarded to the nested member specs (a
* member's own per-task budget is `TaskSpec.maxTokens`/`maxCostUsd`, a different contract).
*/
limits?: {
maxWalltimeMs?: number;
maxTurns?: number;
maxTokens?: number;
maxCostUsd?: number;
};
/**
* External cancellation. When it aborts, in-flight member/synthesizer/summary runs are cancelled
* (each member turn is a nested `runTask` given this signal) — so a team abort cascades to members
* instead of each running to its own `timeoutSec`. Pass a parent tool's `ToolExecuteContext.signal`.
*/
signal?: AbortSignal;
/**
* Authenticated end-user {@link TaskSpec.principal} (design/62) propagated to every member / summarizer /
* synthesizer run, so their MCP tools carry the per-user identity. The orchestrator sets it from the trusted
* task context (a team is a control-plane composition, so it is set here rather than auto-inherited).
*/
principal?: string;
}
export interface TeamResult {
/**
* design/80 D-G data contract: the UNIFORM orchestrator-outcome projection. `team` was the only
* orchestrator exit whose result didn't expose `status`/`errorCode`/`result` (cascade/teacher/verify
* all `extends TaskResult`), forcing aggregators onto a special-case code path. These three projection
* fields close that gap so a consumer can read the SAME failure-class fields off every orchestrator exit
* (e.g. `errorClassOf(r.errorCode)`).
*
* This is a PROJECTION, not `extends TaskResult`: a team is a multi-member discussion with **no single**
* `taskId`/`sessionId`/`stats` shape (its `stats` is `{tokens,turns}` only, and the rich per-task
* `costMicroUsd`/`nested`/`humanReview` lines have no team-level meaning), so forcing those required
* TaskResult fields would invent semantically-wrong values. We project ONLY the three fields aggregators
* read. All are purely ADDITIVE — existing consumers (`conclusion`/`conclusionValid`/`transcript`/`stats`)
* are untouched.
*
* `status` is `"completed"` on a valid synthesis, `"failed"` when synthesis failed or a member durably
* paused; `result` mirrors `conclusion`; `errorCode` is set only on the durable-pause exit
* (`unexpected.suspended` / `unexpected.needs_review`, the same hard-boundary codes the other
* orchestrators use via {@link mapNestedSuspend}).
*/
status: "completed" | "failed";
/** Mirrors {@link conclusion} — the TaskResult-shaped `result` accessor for uniform aggregation. */
result: string;
/** The failure CLASS code (foldable with `errorClassOf`); set only when a member durably paused
* (`unexpected.suspended` / `unexpected.needs_review`). Undefined on a normal completion/synthesis fail. */
errorCode?: string;
conclusion: string;
/** False when the synthesizer failed and `conclusion` is an `[unavailable…]` marker, not a real
* conclusion — so callers can tell a junk conclusion from a legitimate one. */
conclusionValid: boolean;
transcript: TeamTurn[];
/** `costMicroUsd`: cumulative team LLM spend in integer micro-USD (member + summary +
* synthesizer, nested included) — the same engine coordinate as `TaskResult.stats.costMicroUsd`.
* Always set (0 when no run reported cost); optional only for type-level back-compat. */
stats: {
tokens: number;
turns: number;
costMicroUsd?: number;
};
/**
* budget-stop attribution: set when a cumulative budget axis was exhausted and the
* discussion stopped dispatching further members/rounds early. `round`/`role`/`memberIndex`
* identify the LAST member turn that ran (the one whose settled totals crossed the budget);
* everything scheduled after it was skipped and the transcript went straight to synthesis.
* NOT set when the budget was only crossed by the final scheduled member turn (nothing was
* skipped) or by the synthesizer itself — compare `stats` against the budget for that readout.
*/
budgetStop?: {
axis: "maxTokens" | "maxCostUsd";
round: number;
role: string;
memberIndex: number;
};
/** Number of member/synth turns that failed even after a retry (surfaced, not silent). */
failures: number;
/** How many times the shared transcript was summarized to stay under maxTranscriptTokens. */
transcriptCompactions: number;
/** design/80 D-B: set when a member durably PAUSED (suspended/needs_review) on a HITL gate —
* the discussion STOPS (no synthesis on a half-done team) and surfaces the resume capability so the caller
* can resume the paused member via the token, then re-run. `conclusionValid` is false in this case. */
durablePause?: boolean;
checkpointToken?: CheckpointToken;
checkpointGate?: CheckpointGate;
}
/**
* Run a multi-round discussion across several role-specialized agents, then synthesize a conclusion.
*
* C-R14 note: the EXPORTED identifiers of this family still spell `Team*` (`runTeamDiscussion`,
* `TeamDiscussionOptions`, `TeamMember`, …) and `ModelRole` still carries the `"team"` value. Renaming
* them removes public exports / a union member, i.e. a BREAKING change that belongs to its own major
* window — deliberately NOT smuggled into the vocabulary rename. The model-facing text and the framing
* tags this file WRITES already say "discussion".
*
* Each member is shown the shared transcript (quoted as data) plus an explicit instruction block stating
* the discussion context, its role, and who else is present. Members are stateless per round (the shared
* transcript carries continuity), which keeps the orchestration simple and isolated.
*/
export declare function runTeamDiscussion(opts: TeamDiscussionOptions): Promise<TeamResult>;