@sema-agent/core
Version:
Stateless, task-oriented AI agent core
124 lines (123 loc) • 9.57 kB
TypeScript
/**
* PARITY-SPOT-WORKFLOW B5/F2 (CC pretty.js:449105-449141 + `Pqi` :149631) — the script-persistence seam behind
* the Workflow tool's `scriptPath`/`name` calling surface. CC persists EVERY invocation's script under the
* session directory and returns the path in the tool result, so the model iterates by EDITING THE FILE and
* re-invoking with `{scriptPath}` instead of resending the whole script; `{name}` resolves a saved workflow.
*
* Core is deployment-neutral: the tool takes this seam via `RunWorkflowToolDeps.scriptStore` (optional — when
* absent, `scriptPath`/`name` calls get an honest structured error and inline `script` works as before). The
* file-backed implementation below is the TOC default (mirrors CC's session-dir posture: dir 0o700 / file
* 0o600, best-effort persist that never blocks a launch).
*/
/**
* design/140 §6 1a — a named-registry entry's RESOLVED form: the script source plus the registration's
* `defaultArgs` layer (the middle tier of the merge chain: call-time args > registered defaultArgs >
* script-internal fallbacks). ONE return shape: a registry that carries no defaults returns `{ script }`
* — the bare-string return is RETIRED (RB-482 #9, ruled 2026-08-03), matching {@link
* WorkflowScriptStore.resolveName}'s contract. A plain-JS store still returning a bare string is refused
* with a structured migration error at the tool boundary, not read as a resolution.
*/
export interface NamedWorkflowResolution {
script: string;
/** Registration-time default args (a JSON value — this is where a control-plane/center下发的默认 lands).
* Merged UNDER the call-time args by {@link mergeWorkflowArgs} at tool-resolve time (parse-time snapshot:
* the run receives the merged VALUE; nothing re-queries the registry after launch). */
defaultArgs?: unknown;
/**
* CORE-1 — the top-level key a BARE STRING call-time arg normalizes into, so the "裸 string =
* <key>" ergonomic entry (published by the built-in `discussion` script: a bare string args IS the
* topic) composes with a registered object `defaultArgs` instead of colliding with it. When a `{name}` call
* passes a raw string AND this resolution carries an object `defaultArgs`, the tool wraps the string as
* `{ [stringArgKey]: <string> }` BEFORE the object merge (see {@link normalizeStringArg} / the run-workflow
* tool's resolve path). Defaults to `"topic"` (the built-in ergonomic contract). Ignored when `defaultArgs`
* is not a plain object (a bare string then reaches the script verbatim — pre- behavior preserved). */
stringArgKey?: string;
}
/**
* design/140 §6 1b — one row of the named-workflow LISTING projection (the LLM-facing consumption face of
* `meta.whenToUse`). Mirrors the agents-side roster entry (subagent.ts `agentWhenToUseText`/CC `tIl`):
* `whenToUse` is shown when present, else `description`.
*/
export interface NamedWorkflowListing {
name: string;
description?: string;
whenToUse?: string;
}
export interface WorkflowScriptStore {
/** B-4 — the CONFORMANCE MARKER for the scope-partitioned contract. Structural
* typing would otherwise let a pre-partition adapter (`load(path)`, `persist(runId, script)`)
* remain assignable — fewer-parameter functions are compatible in TypeScript — and silently
* ignore the scope argument, serving cross-tenant reads while the tool presents the operation as
* scope-bounded. A REQUIRED literal field breaks that assignability at compile time; the
* run-workflow tool also refuses at runtime when it is absent (plain-JS adapters). */
readonly scopePartitioned: true;
/** Persist THIS invocation's resolved script, keyed by its runId. Returns the persisted path (what the tool
* reports back to the model). Failures should throw — the tool treats persistence as best-effort.
* B-4 (BREAKING): `scope` PARTITIONS per-run scripts — the CC anchor is a per-session directory,
* and the port had dropped the axis (one flat directory let a model-supplied scriptPath read another
* scope's script). Implementations key storage by scope (the file store uses per-scope subdirs). */
persist(runId: string, script: string, scope: string): Promise<string> | string;
/** Read a previously persisted script back. MUST refuse paths outside the store (the argument is
* model-supplied — containment is the store's contract, not the tool's).
* B-4 (BREAKING): `scope` bounds the read to the CALLER's partition — a path pointing into
* another scope's partition must be refused exactly like a path outside the store. */
load(scriptPath: string, scope: string): Promise<string> | string;
/** Resolve a SAVED workflow name to its registration (undefined = unknown name). Optional — a deployment
* without a named registry simply omits it. RB-482 #9 (ruled 2026-08-03): ONE return form — a
* {@link NamedWorkflowResolution} ({ script, defaultArgs? }); the legacy bare-string return is retired. A
* deployment entry of the SAME NAME as a built-in workflow SHADOWS the built-in (design/140 §6 1c).
* RULING: deliberately NO scope axis here — saved names are a DEPLOYMENT-level registry (the
* built-ins' tier), shared across scopes like agent definitions; a multi-tenant deployment that wants
* per-tenant registries mounts per-tenant store instances.
* CONTRACT (stated 2026-08-16, C-R14): this is a STABLE, SIDE-EFFECT-FREE lookup. Resolving ONE call
* may ask it more than once with different spellings of the same slot (a built-in's retired name and
* its canonical one), and the tool card probes it independently of the execute path — so an
* implementation that counts queries, rate-limits, or mutates state per call is out of contract and
* will behave differently depending on how many spellings a name has. */
resolveName?(name: string): Promise<NamedWorkflowResolution | undefined> | NamedWorkflowResolution | undefined;
/**
* design/140 §6 1b — enumerate the registry's SAVED workflows for the listing projection (tool card /
* roster faces). Optional: a store without a stable name registry omits it (the file store below does —
* its directory mixes per-run persisted scripts with saved names, so enumeration would advertise every
* past run as a "named workflow"). NOTE: only a SYNCHRONOUS return reaches the statically-built Workflow
* tool card; an async store should precompute if it wants card visibility.
*/
list?(): NamedWorkflowListing[] | Promise<NamedWorkflowListing[]>;
}
/**
* design/140 §6 1a — the default-args merge: **call-time args > registered defaultArgs** (the script's own
* internal fallbacks remain the third tier, applied by the script itself). Semantics (locked by test):
* - both sides plain objects → SHALLOW top-level merge (`{...defaults, ...call}`): a top-level key present in
* the call args wins WHOLESALE — nested objects/arrays/scalars are replaced, never deep-merged;
* - call args absent (`undefined`) → the registered defaults verbatim;
* - call args a non-object (array / scalar / null) → the call args win wholesale (no structural mixing).
* Pure value-in/value-out: the caller snapshots the RESULT into the run (parse-time discipline, design/140 §2 —
* a resume replay never re-queries the registry; a post-launch registry change cannot reach an in-flight run).
*/
export declare function mergeWorkflowArgs(callArgs: unknown, defaultArgs: unknown): unknown;
/**
* CORE-1 — NORMALIZE a bare-string call-time arg into `{ [key]: <string> }` so the published
* "裸 string = <key>" ergonomic entry (built-in `discussion`: bare string args IS the topic) COMPOSES
* with a registered object `defaultArgs` instead of colliding with it.
*
* The bug it fixes: {@link mergeWorkflowArgs}'s non-object branch lets a bare string win WHOLESALE — so a
* named workflow whose registration carries object `defaultArgs` (center 配的 members/rounds/finalizer) had
* its whole defaults block silently wiped the moment a caller used the ergonomic `{name, args: "<topic>"}`
* form, and the collab template quietly ran as a generic advocate/skeptic. Wrapping the string here lets the
* subsequent object merge preserve the defaults AND honor the topic.
*
* Applied ONLY when BOTH hold (otherwise return `callArgs` untouched — pre- behavior byte-preserved):
* - `callArgs` is a string (the ergonomic form), AND
* - `defaultArgs` is a PLAIN OBJECT (there ARE object defaults to merge under; with no/scalar defaults a bare
* string must still reach the script verbatim — the built-in's own `raw === "string"` fallback handles it).
* `key` is the resolution's `stringArgKey` (a registry may declare which key its bare string maps to); the
* caller passes the built-in default `"topic"` when the resolution omits it.
*/
export declare function normalizeStringArg(callArgs: unknown, defaultArgs: unknown, key?: string): unknown;
/**
* File-backed {@link WorkflowScriptStore}: per-run scripts land as `<dir>/<scopeDir>/<runId>.js`
* (B-4: per-scope partition — the CC per-session-dir posture restored on the tenant axis);
* `resolveName` reads `<dir>/<name>.js` at the ROOT (the deployment-level saved-name tier, see the
* interface RULING — deliberately outside every scope partition).
*/
export declare function createFileWorkflowScriptStore(dir: string): WorkflowScriptStore;