@exellix/graph-engine
v7.0.2
Published
Graph executor SDK
Maintainers
Readme
@exellix/graph-engine — Clean Graph Executor SDK
A minimal, focused SDK for executing graphs in the exellix ecosystem.
What this package does and does not
In scope — what this package does: On each createExellixGraphRuntime(...).executeGraph({ model, runtime }) invocation, it runs exactly one graph run (validate model → plan → nodes → finalizer) until that run completes or fails, then returns the result. There is no batching, queueing, or multi-job orchestration inside this package — each call is one logical run. You supply the static model: GraphModelObject, planner (GraphEngineFactory), tasks client, and dynamic runtime: GraphRuntimeObject with the mandatory host jobId, job envelope, active input, memory, variables, and per-run options. The engine generates a fresh taskId (UUID) per invocation and sends it on every runTask request together with jobId. That is the entire product role of graph-engine.
Out of scope — what this package does not do: It does not schedule work, own execution matrices, manage claims or rows, persist job lifecycle, implement retry/requeue policy, or track runs across tenants or sessions. Optional helpers and docs for matrix hosts only help build arguments for the same single-run API; they do not move orchestration into this package. Integrations (e.g. Activix graph-run events) emit data for that call when you wire an eventEmitter — they do not make graph-engine a workflow or matrix service.
Core Responsibilities
exellix-graph-engine does exactly this loop:
- Validate graph model: The caller supplies
model: GraphModelObjectdirectly in the execution request. The effective correlationgraphIdon the result and in telemetry ismodel.id. plan = graphenix.plan(...)- For each runnable node:
- Map node →
skillKey(strict rules) - If
skillKeyis a local skill (scoped-data-reader,deterministic-rule,scoped-answer-writer,scoped-answer-assembler), run it in-process (noai-taskscall); otherwise callai-tasks.runTask(...) - Commit output into graphenix
- Map node →
- Repeat until done/fail
Nothing else beyond orchestration, memory mapping, finalizers, and the small local-skill surface above — and not matrix/worker/queue ownership (see above).
Specification & documentation
| Topic | Document |
|--------|----------|
| Object contracts (model × runtime + per-call resolution view) | Index: formats-documentations/README.md — graph-model, graph-runtime, task-node-model, task-node-runtime, ExellixRuntimeObject |
| Ecosystem acceptance (ai-tasks, studio, matrix parity) | .docs/ecosystem-acceptance-criteria.md |
| Executable graph JSON (top-level shape, task/finalizer nodes, edges, variables, metadata.graphExecution, canonical root enforcement, execution memory, local skills, Narrix / web scope (forwarded narrix → runTask), graph JSON vs outbound runTask) | .docs/exellix-graph-engine-format.md — start here for authors and schema tooling |
| Task node bridge (shell, metadata, executionPipeline, aiTaskProfile → Narrix web merge, composer alignment) | .docs/task-node-exellix-graph-engine-and-graph-composer.md |
| Layer 01 / 08 graph entry & response contracts | .docs/graph-io-visibility.md |
| Graph entry dataFilters v1 / public evaluator | .docs/data-filters-evaluation.md |
| Task-node conditions + conditional modelConfig.cases (runx) | .docs/task-node-conditions-evaluation.md |
| Model profile aliases (7.x: graph = profile names, runtime = concrete models) | BREAKING-CHANGES.md §7.0.0, .docs/ai-tasks-model-profile-aliases-7x.md (ai-tasks), .docs/fr-model-alias-descriptors.md (upstream FRs) |
| Platform vs implementation (no domain operators in schema) | .docs/platform-generic-vs-implementation.md |
| Bundled graph examples & bundle README | graphs/README.md |
The runTask wire contract (identity, canonical input, optional extra ai-tasks PRE/POST utility calls vs executionPipeline) is summarized later under Run identity and runTask request contract, and expanded in the graph format doc’s Graph JSON vs outbound runTask section. Graph-engine integrates with @exellix/ai-tasks at the semver range declared in package.json (currently ^7.6.4); use your lockfile as the tested line. Variable buckets align with ai-tasks ≥ 7.6.2 (two-scope passthrough). There is no minimum graph-engine ↔ ai-tasks matrix published inside ai-tasks — follow dependency semver and upstream CHANGELOG.md. Graph-engine does not import or invoke the Xynthesis SDK directly.
Canonical executable graph document (strict boundary)
An executable graph model object may have only these top-level keys: id, version, modelConfig, jobKnowledge, nodes, edges, variables, response, metadata. The required root response is the single executable final response contract used to build ExecuteGraphResult.finalOutput. Document-model and authoring fields (name, description, exellixContractTarget, graphExecution, graphEntry, catalogRequests, and similar) belong under metadata only. Node-scoped taskKnowledge belongs under each task node, not at the model root. Runtime state (input, jobMemory, taskMemory, executionMemory, outputsMemory, aliasConfig, runtime node overrides) belongs under the execution request’s runtime object and is rejected on the model.
metadata.graphExecution can document graph execution defaults and labels, including mode: 'forward' | 'backward' | 'hybrid', optional goalNodeId, optional dimension, outputMode: 'mappedAggregation' | 'lastExitNode', coreObjective, optional nodesResponses, and metadata-only flowOutline: 'linearSequence' | 'convergingParallelFlow'. Planner mode still comes from executeGraph({ model, runtime }).runtime.mode; outputMode does not decide the returned finalOutput.
Enforcement: executeGraph, createExellixGraphRuntime().executeGraph, executeNode when a graph is passed in context, inspectGraph, inspectGraphContracts, validateCatalogPlanning, and Catalox graph validators call this check. Failures throw ExellixGraphError with code NON_CANONICAL_GRAPH_DOCUMENT. For CI or custom loaders, call assertCanonicalGraphDocument (exported from the package root) on parsed JSON before execution. loadGraph returns whatever the loader parsed; validation runs when you execute or inspect, not necessarily on load.
Installation
npm install @exellix/graph-engineUpstream tasks SDK: This package depends on @exellix/ai-tasks ^7.4.0 (see package.json). Graph-engine emits RunTaskRequest shapes that match the v7 closed schema (mandatory executionStrategies, xynthesized, optional smartInput, no legacy root mirrors). Pin compatible versions in your app lockfile. Optional CI improvement: fail or warn when the resolved ai-tasks major/minor drifts outside an allowlist (not enforced in-repo today).
Execution matrix hosts (@exellix/exellix-runtime) — documentation only for the engine
Matrix claim, rows, and retry policy live outside this package. If your host wires matrix → graph run, see .docs/execution-matrix-handoff.md for how you should inject runtime.executeGraph, resolve metadata.graphEntry per model, seed runtime.executionMemory, and pass runtime.jobId (see Run identity below). Helpers such as buildMatrixJobForGraphRun align id and job.jobId on the job object so you can call runtime.executeGraph({ model, runtime: { jobId: job.jobId, job, … } }). Those exports are optional helpers for callers; they do not expand graph-engine’s role beyond executing the single run when invoked.
Configuration (.env)
- Template:
.env.example— copy to.envat the project root for local tests and scripts that loaddotenv. - Semantics:
.docs/environment-and-xmemory-databases.md(must-have vs nice-to-have, Mongo / xmemory / Narrix / Activix). - Resolvable path after install: subpath export
@exellix/graph-engine/env.examplepoints at the same file (e.g.require.resolve('@exellix/graph-engine/env.example')in Node).
Package entrypoints
@exellix/graph-engine— Graph executor, types, loaders, integrations (platform).@exellix/graph-engine/testkit— Harness helpers (InMemoryGraphLoader,DepGraphEngineFactory,RealTasksClient) and sampleregisterNarrixGraphTasksfor repo graphs that still callnarrix/load-input,narrix/to-cni, etc. This is not re-exported from the root entry.
Catalox and graph planning catalog IDs
Skill templates and Catalox wiring for packaged runs live in @exellix/ai-tasks (and its upstream stack). This package only offers an optional planning-descriptor check when you already have a Catalox client:
validateGraphPlanningCatalogDescriptorsInCatalox(graph, catalox, ctx)— For everycatalogIdcollected frommetadata.catalogBinding/ planning metadata (seegetGraphCatalogs), assertscatalox.getCatalogDescriptor(ctx, catalogId)is non-null.
For Firebase Admin, createCataloxFromEnv, listAiSkillsCatalogItems, and related helpers, import from @exellix/ai-tasks or the graph-engine re-exports below instead of adding @exellix/ai-skills here.
taskConfiguration.aiTasksOutputValidation (shape: { schema, mode?, validateWhenMissing? }) is forwarded on runTask as outputValidation for server-side checks in @exellix/ai-tasks. The top-level node field outputValidation with rules remains a local post-check in executeNode only.
Task-node preflight (validation & analysis, no runTask)
Graph-engine exposes the @exellix/ai-tasks ≥ 7.6 preflight surface so studios and matrix hosts can validate a node before execution without a second dependency:
| Export | Purpose |
|--------|---------|
| buildTaskNodeRunTaskRequest | Build the same outbound RunTaskRequest as executeNode (skips local skills and finalizers). |
| validateTaskNodeRunTaskConfig | Static config checks (agentId, pipeline, smartInput, llmCall, …). |
| validateTaskNodeRunTaskInvoke | Config + payload path resolution + optional template/smart-input render checks. |
| analyzeTaskNodeRunTaskRequest | Catalox-backed skill request analysis (templates, gateway packet preview) plus optional config validation. |
Lower-level helpers (validateRunTaskConfig, validateRunTaskInvoke, analyzeRunTaskRequest, analyzeSkillRequest, formatSkillRequestAnalysisMarkdown, Rendrix listTokens / analyzeTemplateResolution, …) are re-exported from the package root when you already have a RunTaskRequest.
Testing safety: Default npm test uses mocked Catalox in catalog validation tests and does not open Firestore. Do not point FIRESTORE_LIVE_TESTS / integration flags at a production Firebase project; Catalox’s own docs recommend a dedicated test project for live integration runs.
Quick Start
import { createExellixGraphRuntime } from '@exellix/graph-engine';
const runtime = createExellixGraphRuntime({
graphLoader: myGraphLoader,
engineFactory: myEngineFactory, // GraphEngineFactory (e.g. DepGraphEngineFactory)
tasksClient: myTasksClient, // TasksClientLike — responses may use `ok` or `success` (both accepted)
});
// Host correlation id (required). Engine sets `job.id` / `job.jobId` from it and generates `result.taskId`.
const result = await runtime.executeGraph({
model: graphModel,
runtime: {
jobId: 'job-123',
job: { agentId: 'agent-1', input: {} },
input: { question: 'Analyze this record' },
// 7.x: graph modelConfig values are profile aliases; concrete models bind here (required).
modelConfig: {
cases: [{ modelConfig: { xynthesisModel: 'weak', skillModel: 'strong' } }],
},
aliasConfig: {
strong: 'anthropic/claude-sonnet-4',
weak: 'google/gemini-2.5-flash',
default: 'google/gemini-2.5-flash',
},
},
});
// Canonical business output + per-run ids:
console.log(result.finalOutput, result.jobId, result.taskId);Public API
runtime.executeGraph({ model, runtime })
Execute a complete graph through the single canonical client API: createExellixGraphRuntime(...). The runtime owns local-skill interception, conditional edge filtering, optional eventEmitter, optional debugMode, and produces one ExecuteGraphResult shape.
Every call requires a static model: GraphModelObject and a dynamic runtime: GraphRuntimeObject. runtime.jobId is mandatory and non-empty. The engine also generates a taskId (UUID) per invocation. Together they form the identity forwarded to @exellix/ai-tasks (runTask({ jobId, taskId, … })), graph/node eventEmitter payloads, structured runLog, and Activix runContext / record metadata.
import { createExellixGraphRuntime } from '@exellix/graph-engine';
const runtime = createExellixGraphRuntime({
graphLoader,
engineFactory,
tasksClient,
modelConfig, // optional explicit fallback: { xynthesisModel, skillModel }
eventEmitter, // optional graph/node lifecycle events
playgroundReporter, // optional
// …stepRetryPolicy, runLogMode, concurrency, runTaskDiagnostics, etc.
});
interface GraphExecutionRequest {
model: GraphModelObject; // static graph definition
runtime: GraphRuntimeObject; // dynamic run state
}
// 2x2 object split:
// Graph model: GraphModelObject
// Graph runtime: GraphRuntimeObject
// Task-node model: TaskNode
// Task-node runtime: TaskNodeRuntimeObject at runtime.nodes[nodeId]
interface GraphRuntimeObject {
jobId: string; // required: host correlation id
job: any; // host envelope: agentId, input, jobType, …
input?: Record<string, any>; // active execution input
jobMemory?: any;
taskMemory?: any;
executionMemory?: any;
variables?: Record<string, any>;
/** Run-level model profile override (`cases`); values are alias names, not provider ids. */
modelConfig?: { cases: Array<{ when?: unknown; modelConfig: { xynthesisModel: string; skillModel: string } }> };
/** Required (7.x): profile alias → concrete provider model id for this execution. */
aliasConfig: Record<string, string>;
nodes?: Record<string, {
/** Per-node profile override (alias names). */
modelConfig?: { xynthesisModel: string; skillModel: string };
/** Per-node alias bindings (overlay `aliasConfig`). */
aliasConfig?: Record<string, string>;
}>;
mode?: 'forward' | 'backward' | 'hybrid';
goalNodeId?: string; // required when mode === 'backward'
debugMode?: boolean; // include per-node trace on result.debug
failFast?: boolean; // default: false
// …llmCall, stepRetryPolicy, runLogMode, runtimeObjects, runTaskDiagnostics, etc.
}Return shape
interface ExecuteGraphResult {
jobId: string;
taskId: string;
graphId: string;
status: 'completed' | 'failed';
finalOutput?: unknown;
finalizerNodeId?: string;
finalizerType?: string;
outputsByNodeId: Record<string, unknown>;
stepsResponses: Record<string, unknown>[];
engineSnapshot: unknown;
errors?: Array<{ nodeId?: string; error: unknown }>;
execution?: unknown; // includes _trace.nodes
runLog?: RunLogEntry[];
runLogTruncated?: boolean;
runLogOmittedCount?: number;
logxerCorrelationId?: string;
debug?: { nodes: NodeTraceEntry[] }; // populated only when debugMode: true
graphAudit?: {
source: 'model';
contentSha256: string; // stable JSON + SHA-256 of supplied model (audit / matrix persistence)
};
}Host HTTP handlers or workers should accept the same GraphExecutionRequest shape in the request body so payloads stay aligned with runtime.executeGraph.
Memory-only playground reporter
Use createPlaygroundReporter() when you want rich per-run debugging without filesystem output. The reporter is in-memory only: it does not accept an output directory, does not expose writeReport, and never writes request/response payloads to playground/, reports/, or any other path.
import { createExellixGraphRuntime, createPlaygroundReporter } from '@exellix/graph-engine';
const playgroundReporter = createPlaygroundReporter({ runId: 'local-debug-run' });
const runtime = createExellixGraphRuntime({
graphLoader,
engineFactory,
tasksClient,
playgroundReporter,
});
await runtime.executeGraph({
model,
runtime: {
jobId: 'job-123',
job,
input,
},
});
// Inspect full node request/response payloads in memory.
const artifacts = playgroundReporter.getArtifacts();
const snapshot = playgroundReporter.getDebugSnapshot();
const markdown = playgroundReporter.getMarkdown();getArtifacts() returns entries such as 01-<nodeId>-request and 01-<nodeId>-response with the full payload attached as payload. Use getDebugSnapshot() when a UI, debugger panel, or test wants steps, artifacts, and rendered markdown as one object.
Removed in 5.0: the legacy functional executeGraph from runtime/executeGraph, the ExellixGraphClient class, and their option/result types (ExecuteGraphOptions, ExecuteGraphResponse, ExecuteGraphFinalizedResponse, ExecuteGraphDebugResponse, GraphExecutionResult). Migrate to createExellixGraphRuntime(...).executeGraph(...) — the runtime now covers the same semantics and exposes the same diagnostics through debugMode: true. See BREAKING-CHANGES.md.
Run identity (host jobId and engine taskId)
jobId(mandatory): You must passruntime.jobId: stringon theexecuteGraphinput. It must be non-empty after trim. If it is missing or blank, the call fails withExellixGraphErrorCode.JOB_ID_REQUIRED. The runtime setsjob.idandjob.jobIdto this value for the duration of the run (so templates, events, and memory see a consistent id even ifjobomittedidon input).taskId(mandatory on the wire, generated here): At the start of eachexecuteGraph, the engine allocatestaskId = randomUUID(). The same value is attached to everyrunTaskrequest in that run (including synthesize-finalizer paths) astaskId, and is returned onExecuteGraphResult.taskId. This satisfies downstream expectations that each graph execution has a stable per-run task identity distinct from the hostjobId.Results:
ExecuteGraphResultincludes bothjobIdandtaskIdso callers and logs can correlate host scope vs engine run instance.Activix: Graph-run integration persists
jobId,taskId, andgraphIdon the record and inrunContext(withsessionIdstill aligned tojobIdfor Activix v5). The in-memory correlation key for start → complete/fail isjobId:graphId:taskIdso concurrent retries of the same host job against the same graph do not collide. Node activity integration keys rows bygraphId:nodeId:taskIdand includestaskIdinrunContextand top-level metadata.Helpers:
assertHostJobIdandnewGraphRunTaskIdare exported from the package root for hosts/tests that build inputs outsideexecuteGraph.
Standalone node debugging: The runtime also exposes runtime.executeNode(...) for single-node test runs. Provide node, job, optional graph / execution, and (when continuing an existing run) graphRunTaskId from the parent ExecuteGraphResult.taskId so runTask and Activix stay aligned.
runTask request contract (@exellix/ai-tasks v7.x)
Graph-engine builds a canonical RunTaskRequest for every outbound task call it owns: MAIN task-node invokes, engine PRE/POST utility invokes, and synthesize finalizer invokes. MAIN request assembly lives in src/runtime/buildAiTasksRunTaskRequest.ts; all paths follow RUNTASK_REQUEST.md in @exellix/ai-tasks. Types align with RunTaskRequest / ExellixGraphRunTaskRequest exported from this package.
Identity and payload
- Required correlation:
agentId,jobTypeId,taskTypeId(in addition toskillKey,input). Defaults:jobTypeId←job.jobTypeorjob.jobTypeIdorexellix-graph-job;taskTypeId←node.taskConfiguration.taskTypeIdor the node’sskillKey. - Canonical task payload:
inputobject only (merged execution slice + materializednode.inputs). Root-levelquestion,raw,jobInput, duplicateinputs, and legacyexecutionTypeare not sent on the request object. - Graph telemetry:
graphId,nodeId,coreSkillId(node id),masterSkillId,masterSkillActivityId,jobId,taskId, optionalidentity.
To build RunTaskRequest without fallback defaults, the execution request must provide both sides of the contract: model.id, node.id, node.skillKey, explicit node.taskConfiguration.taskTypeId (even when it matches skillKey), node.taskConfiguration.executionStrategies (use [] for plain MAIN), runtime.jobId, runtime.job.agentId, runtime.job.jobTypeId or runtime.job.jobType, active input/memory, and any model/LLM/diagnostic options needed by the task.
Model profiles (7.x): Graph and node modelConfig carry profile alias names only (strong, weak, default, …) — never provider model ids in graph JSON. runtime.aliasConfig is required and maps every alias used by the run (including default when fallback applies) to concrete provider models. Selection order: runtime.nodes[nodeId].modelConfig → node.taskConfiguration.modelConfig → runtime.modelConfig → model.modelConfig → implicit { default, default }; then strict resolution through runtime.aliasConfig plus runtime.nodes[nodeId].aliasConfig. The resolved { xynthesisModel, skillModel } is forwarded to MAIN, engine PRE/POST utility calls, and synthesize finalizer calls. Snapshot aliasConfig on execution records for reproducibility. See BREAKING-CHANGES.md §7.0.0.
Graph-engine still derives correlation fields such as graphId, nodeId, coreSkillId, masterSkillId, taskId, and masterSkillActivityId from those authored values.
Mandatory executionStrategies (breaking vs pre–v7 authoring)
- Every MAIN (and engine PRE/POST utility)
runTaskincludesexecutionStrategies: an array ofExecutionStrategyInvocationobjects (semantics defined by@exellix/ai-tasks/RUNTASK_REQUEST.md). - Plain MAIN (no wrappers): graph-engine sends
executionStrategies: []. - Optional task-node authoring:
taskConfiguration.executionStrategies— when present and non-empty, it overrides the default[]for that node’s MAIN call. - Optional catalog metadata:
taskConfiguration.executionStrategyCatalogItemsis forwarded toai-tasks;ai-tasksstill validates the runtime invocation shape and consumes only safe catalog fields such as wrapper default function ids. - Removed in 5.0:
metadata.executionStrategyKeytyping and code branches. Configure planners/optimizers throughexecutionStrategiesper ai-tasks (see upstream docs / Catalox task-strategy catalogs).
xynthesized and internal execution.xynthesized
- Outbound
runTask.xynthesized:{ job, task }snapshot from durable graph-engine memory —job=execution.xynthesized.job,task=execution.xynthesized.taskByNode[nodeId](never another node’s task bucket). - After a successful MAIN
runTask, graph-engine deep-mergesresponse.xynthesizedPatchintoexecution.xynthesized(patch.job→job,patch.task→taskByNode[nodeId]). - On graph start,
seedGraphRunExecutionStateensuresexecution.xynthesizedexists withjobandtaskByNodeobjects.
smartInput
- Optional task-node field
smartInput(paths: string[], optionalstrict) is forwarded onRunTaskRequest.smartInputwhen set. Paths are validated against graph-engine allowlists (see catalog planning /validateAiTasksNodeExtensions).
Optional taskConfiguration → runTask (strategy / Narrix)
Forwarded from taskConfiguration: narrixMode, inputStrategyKey, narrixInput, and executionStrategyCatalogItems. If both taskConfiguration.narrix and taskConfiguration.narrixInput are set, set taskConfiguration.narrixMode to preprocessor or handler (see format doc).
Input bindings before runTask
Values in node.inputsConfig (or deprecated node.inputs) shaped as { type: 'executionMemoryPath', path } (optional optional: true) or { $path: '…' } are resolved against the live execution object before building runTask.input, so chained graphs see concrete values (e.g. graphOutputs.*) instead of binding objects.
Graph-run execution seeding
When executeGraph / createExellixGraphRuntime().executeGraph starts, graph-engine seeds runtime.executionMemory.xynthesized and mirrors runtime.input to runtime.executionMemory.input (flat fields on the object; no input.raw wrapper).
Where hosts put the record: .docs/graph-execution-record-input.md — peer contract (runtime.input only, flat paths, matrix vs BFF).
Model knowledge references are resolved inside graph-engine without changing the ai-tasks wire contract. The merge happens when graph-engine builds the outbound RunTaskRequest: model.jobKnowledge is applied to the request copy at jobMemory.knowledge, and node.taskKnowledge is applied to that task node's request copy at taskMemory.knowledge. The shared runtime.jobMemory / runtime.taskMemory objects are not rewritten just to attach model knowledge.
Scope matters: jobKnowledge is graph-run scoped and is available to every task request through jobMemory.knowledge. taskKnowledge is node/task scoped and must be declared on the task node; root model.taskKnowledge is not part of the graph model contract.
PRE inputSynthesis (authoring → pipeline)
When taskConfiguration.aiTaskProfile.inputSynthesis is enabled, graph-engine may synthesize or merge a PRE synthesized-context step (still one outbound MAIN runTask). Conflicts with an explicit node.executionPipeline that already defines PRE synthesis are rejected at runtime / validation with INPUT_SYNTHESIS_PIPELINE_CONFLICT. Details: .docs/exellix-graph-engine-format.md.
Three layers (do not confuse)
- Engine PRE/POST strategy utilities (ai-tasks only):
taskConfiguration.aiTaskProfile.preStrategyKey/postStrategyKey→ extrarunTaskcalls via@exellix/ai-tasksbefore/after MAIN; outputs stored atexecution.xynthesis.pre/execution.xynthesis.post(historical slot names). executionPipelineinside ai-tasks: e.g. PREsynthesized-context+ MAIN direct — still one outbound MAINrunTaskhandled inside@exellix/ai-tasks.- Narrix web scope inside ai-tasks: when
taskConfiguration.aiTaskProfile.webScoping.enabledis true, graph-engine forwards anarrixpayload withenableWebScope/webScopeQuestions. The actual web fetch + skip rules run inside@exellix/ai-tasks(@exellix/narrix-web-scoper); graph-engine has no local web phase in 5.x.
synthesize finalizers are a fourth outbound task-call shape: the finalizer is still a graph model node, but its terminal utility runTask uses the same run identity, diagnostics, llmCall, and resolved model config as the rest of the graph run.
Finalizer nodes & Final Response
Executable graphs must include exactly one terminal finalizer node (type: "finalizer") with no outgoing edges.
Required finalizer reads must resolve from the selected memory lane. A non-optional executionMemoryPath read must either be seeded by runtime.executionMemory before the run starts or be written by a reachable upstream task node through executionMapping.path. A non-optional outputsMemoryPath read must either be seeded by runtime.outputsMemory or be written by a reachable upstream task node through outputMapping.path. If the value may be absent because a branch is conditional, mark that finalizer input, section, or item optional: true.
The finalizer is a terminal computation or fan-in barrier. It does not own the returned API response. The returned ExecuteGraphResult.finalOutput is always resolved from root model.response after node executionMapping/outputMapping writes and finalizer/barrier execution complete:
type GraphResponseDefinition = {
missing?: 'omit' | 'null';
shape: unknown;
};Supported selectors are outputsMemoryPath, executionMemoryPath, executionPath, nodeMetadata, nodeInputsConfig, literal, and firstPresent. Legacy nodeInputs is still accepted. Missing selector values are omitted by default; missing: "null" returns null for missing mapped fields.
Example:
{
"response": {
"missing": "omit",
"shape": {
"answer": {
"type": "executionMemoryPath",
"path": "answers.q1.shortAnswer"
}
}
}
}metadata.graphExecution.outputMode, finalizer output, and legacy metadata.graphResponse.responseMapping are not final-output selectors for new graphs. Migrate legacy response mappings by copying missing and shape to root response and dropping version, target, primaryResponsePaths, debugResponsePaths, notableExecutionPaths, and mappingPreset.
coreObjective is resolved once from graph/run context, not from each node response. Supported sourcePath roots are input.*, execution.*, variables.* / jobVariables.*, taskVariables.*, jobMemory.*, taskMemory.*, and job.*.
The selected canonical business output is exposed as:
ExecuteGraphResult.finalOutput(the only API in 5.x).
Deterministic finalizers (runtime-owned)
Implemented deterministic finalizer types:
aggregatebundleselect
aggregate — strategy: "object-map"
Build an object by mapping named inputs (from executionMemoryPath or literals) into output keys.
aggregate — strategy: "report-schema" (multi-section report from execution memory)
Builds a single object whose keys come from config.sections: each section specifies a dot-path into executionMemory (path), optional title, and optional (when true, missing values become null instead of throwing).
collect_tags: whentrue, walks all section values and collects unique epistemic strings amongCONFIRMED,INFERRED,ASSUMED,UNKNOWNintocollected_tags(array).meta: optional literal key/value pairs merged at the top level of the parsed output.
Bundled graphs under graphs/ (see graphs/README.md) use this strategy for multi-section reports, sometimes after scoped-answer-assembler and scoped-answer-writer persistence (typical store: x-scoped-data in deployments that use that collection). The graph file shape is defined in .docs/exellix-graph-engine-format.md.
aggregate — strategy: "question-driven" (generic Q→A formatting)
For “question-driven” graphs, question-driven formats the final output by pairing:
- question text from the referenced node definition (default path:
inputs.question) - answer from
executionMemoryat the referenced node’soutputMapping.path(or an explicitanswerPath)
Example:
{
"id": "finalize",
"type": "finalizer",
"finalizerType": "aggregate",
"inputs": {},
"config": {
"strategy": "question-driven",
"contractVersion": "1",
"items": {
"exploitability": { "nodeId": "q2-exploitability" },
"exposure": { "nodeId": "q5-exposure" },
"posture": { "nodeId": "q6-posture" }
}
}
}Output shape (per item key):
{
"exploitability": { "question": "...", "answer": { /* whatever the node mapped */ } }
}Activix integration (graph run record)
When using createActivixGraphRunIntegration() / createActivixExellixIntegration():
- Canonical response is stored at
outer.output.responseand is only the graph’sfinalOutput. - Detailed execution data (nodes, execution memory, errors, etc.) is stored under
outer.output.data.
This keeps the “business output” clean while preserving full diagnostics.
Graph start — bounded input summaries (Activix)
On graph:start, the graph-run integration does not persist raw data.input / data.request (no shallow copy of variables, full GraphExecutionRequest, or opaque host objects). Activix receives JSON-serializable summaries under outer.input:
inputSummary— bounded shape for the event’sinput(e.g.variables/ memory handles as key counts, shallow primitive previews, nested samples).requestSummary— same for the mergedrequestobject emitted with the start event.
Correlation fields (jobId, graphId, agentId, jobType, inputVariableKeys, runContext, etc.) are unchanged.
Recommended for job / GraphExecutionRequest / event data (Activix-safe): JSON-like primitives, plain objects/arrays, string ids, agentId, jobType, and small metadata you are willing to see reflected in summaries.
Not suitable to rely on for Activix persistence at graph start: functions, class instances, streams, Buffers, live clients (HTTP/DB), circular graphs, or very large nested payloads — they are summarized or typed (e.g. shape: "function", shape: "instance") and never passed through to startRecord as raw references. For large memory, use includeMemorySnapshots: true only when you explicitly accept storing outer.memory.start / end; that remains opt-in and separate from outer.input.
runtime.executeNode(input)
Execute a single node — useful for tests and for stepping through a node in the context of an existing graph run. Pass node, job, optional graph / execution, and (when continuing a parent run) graphRunTaskId from the parent ExecuteGraphResult.taskId so runTask correlation, Activix records, and runLog lines stay aligned.
loadGraph(graphId)
Load a graph through the injected loader and validate against the canonical document schema before returning.
Graph Entry Inputs
Graph JSON can declare the semantic type of input it is designed to work with under metadata.graphEntry.inputs. This is part of the graph object itself and is intended for authors, catalogs, dashboards, and future validators; the runtime does not validate or coerce it today.
{
"id": "network-vuln-triage.v1",
"metadata": {
"graphEntry": {
"summary": "Triage one vulnerability record with an optional caller query.",
"inputs": [
{ "kind": "record", "path": "input", "required": true },
{ "kind": "query", "path": "input.query", "required": false }
],
"requiredExecutionPaths": ["input.subnetId"]
}
},
"nodes": []
}Standard kind values are record, query, user-input, content, metadata, and execution. Use multiple entries for combinations, for example a graph that needs both a raw record and a user question. Paths are relative to the merged execution object, so input maps to GraphRuntimeObject.executionMemory.input after runtime.input is mirrored.
Use kind: "execution" when the graph is designed to consume a prior graph execution. The design contract names the source graphId and an optional metadataFilter for fields such as status values:
{
"kind": "execution",
"graphId": "network-vuln-group-triage.v1",
"metadataFilter": { "status": "completed" },
"path": "input.upstreamExecution",
"required": true
}The runtime does not perform that lookup today; host tooling resolves it and passes the selected execution under the declared path.
Node Inputs
All dynamic per-node values — including the question text sent to the template — must be defined under node.inputs. This is the single canonical location.
{
"id": "q1-reachability",
"skillKey": "professional-answer",
"inputs": {
"question": "Is this asset reachable from outside the perimeter?",
"record": { "type": "executionMemoryPath", "path": "input" }
}
}Execution-memory bindings (chaining): Objects { "type": "executionMemoryPath", "path": "graphOutputs.upstream.payload" } (and { "$path": "executionMemory.…" } / input.… forms) are resolved against the live execution object before runTask. The outbound request carries scalar/object values under input, not the binding shape. Use optional: true on executionMemoryPath bindings if the path may be missing.
Optional smartInput: Top-level on the task node (sibling to inputsConfig): { "paths": ["input", "graphOutputs.prev"], "strict": true } — forwarded on RunTaskRequest.smartInput; paths must satisfy graph-engine allowlists.
Rules:
node.inputs.question— the question text sent to the skill template as{{question}}. Always set it here.- Any other per-node dynamic value (e.g.
record,context) also belongs ininputs. - Path references in
outputMappingusenode.inputs.question(e.g."question": "node.inputs.question"). node.inputsis the only place the runtime readsquestionfrom. There is no fallback tonode.questionornode.data.question.
What does NOT belong in inputs:
skillKey— top-level node field.taskConfiguration.narrix— NARRIX engine config (datasetId,questionId,layer, etc.). These are routing/filter fields consumed by the NARRIX pre-processor, not by the template.taskConfiguration.aiTaskProfile.webScoping— opt-in web context. Graph-engine forwards web intent on the outboundnarrixpayload; actual web fetch and skip rules run inside@exellix/ai-tasks. See .docs/exellix-graph-engine-format.md — Web scoping behavior.- Template variables — use
node.variables+ pathstaskVariables.*, ormodel.variables+jobVariables.*(see Variables (two buckets)). Usenode.taskVariablefor prompts, notinputs.
taskConfiguration.narrix.questionId(e.g."q1","q6") is a NARRIX internal routing key that tells the pre-processor which framework question slot this node fills. It has nothing to do withinputs.question(the human-readable question text). Do not confuse them.
First-class: question and questionId. For NARRIX nodes you can set inputs.question, or taskConfiguration.narrix.questionId, or both. Once NARRIX provides a question ↔ questionId resolver, the missing one can be filled automatically. See .docs/question-questionId-first-class-experience.md.
Skill Key Resolution
Canonical graph models must specify node.skillKey for remote task nodes. Legacy aliases node.data.skillKey and node.metadata.skillKey are rejected by canonical validation. Optional fallback to tasks/${node.id} exists only when allowFallbackToNodeId: true is explicitly enabled (default: disabled).
If skillKey is missing, exellix-graph-engine throws NODE_SKILLKEY_MISSING error before execution begins.
Configuration
skillKeyResolution: {
allowFallbackToNodeId?: boolean; // default: false (strict mode)
fallbackPrefix?: string; // default: "tasks/"
aliases?: Record<string, string>; // optional explicit remaps
}Variables (two buckets — job + task)
Template variables are not merged into one bag. Graph-engine mirrors two scopes on executionMemory:
| Scope | Runtime inputs | Execution mirror | Memory paths |
|-------|----------------|------------------|--------------|
| Job / graph (whole run) | model.variables, runtime.variables, runtime.jobVariables, legacy job.jobVariables | execution.jobVariables | jobVariables.* (legacy alias variables.*) |
| Task / node (current node) | node.variables, runtime.taskVariables | execution.taskVariables | taskVariables.* |
Outbound runTask sends variables (ai-tasks field name) = job bucket only; node scope stays on executionMemory.taskVariables. Requires graph-engine ≥ 5.13 and @exellix/ai-tasks ≥ 7.6.2 (passthrough, no flattening).
Important: graph-engine does not define skill templates; it forwards buckets as-is. See
formats-documentations/graph-runtime-object-format.md.
Upstream template rendering (ai-gateway / athenix-parser)
The stack that eventually renders skill prompts pulls in @athenices/ai-gateway (transitively via @exellix/ai-tasks / @exellix/ai-skills), which depends on @athenices/athenix-parser v4+. That parser implements the v4 template protocol: required {{path}} values throw TemplateResolutionError when missing; optional tokens use {{path |}} or {{path | fallback text}}; subPathSearch is opt-in for alternate root lookup. See the parser’s README under node_modules/@athenices/ai-gateway/node_modules/@athenices/athenix-parser/ when debugging template failures.
Web research in prompts: when this repo runs local question-driven web scope in executeNode, it still injects a flat webContextMarkdown string into jobContext so templates can use {{#if webContextMarkdown}} with a bounded markdown block. That remains the default pattern here even though deep execution.* paths are now supported upstream if you pass full context and configure the gateway/parser accordingly.
Web scoping now runs inside @exellix/ai-tasks, not as a local graph-engine web-scoper phase. Author web intent under taskConfiguration.aiTaskProfile.webScoping; downstream ai-tasks controls source snippets, markdown, skip rules, and any enrichment from execution.input.
Memory Wiring
Memory is passed through consistently:
jobMemory = runtime.jobMemory; model knowledge is added only to the outboundRunTaskRequest.jobMemory.knowledgecopy.taskMemory = runtime.taskMemory; node task knowledge is added only to that node's outboundRunTaskRequest.taskMemory.knowledgecopy.
Optional per-node memory overrides:
node.memory?.taskMemorynode.memory?.jobMemory(rare, but allowed)node.jobContextMapping(see below)
Input Grouping (inputs)
Nodes can group their dynamic inputs under an inputs object for better structure and to avoid conflicts with system fields.
{
"id": "node-1",
"type": "task",
"skillKey": "my-skill",
"inputs": {
"question": "What is the capital of France?",
"detailed": true
}
}Job Context Mapping (jobContextMapping)
jobContextMapping allows a node to pull specific data from jobMemory into its local execution context (jobContext).
"jobContextMapping": {
"map": {
"customer": "jobMemory.profiles.current",
"history": "jobMemory.activityLog"
}
}The runtime will resolve these paths from jobMemory and provide a jobContext object to the underlying task.
Path expressions in map support:
- Dot paths:
jobMemory.profiles.current,executionMemory.graphOutputs.vulnInstances.records - Array wildcard
[*]: e.g.executionMemory.graphOutputs.vulnInstances.records[*]— selects all array elements - Object values
{*}: e.g.recordsById{*}— selects all values of an object (deterministic key order)
Sources can be jobMemory.* or executionMemory.* (current execution state from previous nodes).
Execution Object & Trace
Execution Object
The execution object is a shared state container that persists across all nodes in a graph execution. It stores:
- Output mappings: Data written by nodes via
outputMapping xynthesizedmemory:execution.xynthesized.job(run-wide) andexecution.xynthesized.taskByNode[nodeId](per node). OutboundrunTask.xynthesizedexposes the current node’s slice;xynthesizedPatchon responses is merged after successful MAIN calls.- Execution trace: Per-node execution metadata (see below)
- Custom state: Any application-specific data
The execution object is passed to each node as executionMemory in the task request and updated through outputMapping configurations. Edge predicates may also read input.* and xynthesized.* roots via the same evaluation context (see src/runtime/predicates.ts).
Execution Trace (FR-6)
Each graph run records a structured trace per node in the execution object:
- Location:
execution._trace.nodes[nodeId] - Shape:
{ startedAt, endedAt, skillKey, ok, durationMs, activityId?, summary?, error? }
Trace fields:
startedAt: Unix timestamp (ms) when node execution startedendedAt: Unix timestamp (ms) when node execution endedskillKey: The resolved skill key for this nodeok:trueif successful,falseif faileddurationMs: Execution duration in millisecondsactivityId: Optional activity/task identifier from ai-taskssummary: Optional metadata from task response (on success)error: Error details withmessage, optionalcode, and optionalstack(on failure)
Trace is written by the runtime for every node (both executeGraph and createExellixGraphRuntime flows), regardless of success or failure.
Execution Object in Events (FR-3)
All node execution events include the execution object merged into jobMemory:
- Node Start Event:
input.jobMemory.executioncontains the current execution state - Node Complete Event:
memoryAfter.jobMemory.executioncontains the updated execution state (after outputMapping) - Node Fail Event:
memoryAfter.jobMemory.executioncontains the execution state at the time of failure
This ensures consistent tracking and allows activity tracking systems to access execution data for monitoring and debugging.
ai-tasks metadata for trace
For rich structured trace, local tasks (ai-tasks) should return metadata in the response so the graph can store it in execution._trace.nodes[nodeId].summary and use it for durationMs / activityId:
response.parsed.meta.durationMs— task duration in milliseconds (otherwise the runtime usesendedAt - startedAt)response.parsed.meta.localTaskIdorresponse.parsed.meta.activityId— optional activity/handler identifier
MAIN runTask traces may also include bounded summaries of smartInput.paths and keys touched by xynthesizedPatch (not full synthesized payloads).
Any other fields in response.parsed.meta are stored in summary.
Contextual Knowledge Scope (scope)
Nodes can define complex filtering rules to scope lists from memory into the node's context. This is useful for building localized knowledge for a task (e.g., "all assets in the same zone as the target").
{
"scope": {
"contextualKnowledge": [
{
"list": "zonesEnriched",
"select": ["zone", "semantics.trust_level"],
"filter": {
"where": [
{ "path": "virtual_router", "eq": "{{asset.virtual_router}}" },
{ "path": "containsTargetIp", "eq": true }
],
"limit": 10
}
}
]
}
}Features:
- Dynamic Filtering: Use placeholders like
{{asset.zone}}to reference variables or memory. - Operators:
eq,eqAnyonly (precompute domain-specific fields in the host when needed). - Selection: Pick specific fields using
selectto minimize context size. - Integration: The result is merged into
jobContextalongsidejobContextMapping.
Backward Planning
When mode === "backward":
goalNodeIdis requiredgoalNodeIdmust exist in the graph
If missing/invalid, exellix-graph-engine throws BACKWARD_GOAL_REQUIRED error before execution begins.
Error Handling
All errors are structured ExellixGraphError instances with:
code:ExellixGraphErrorCode(e.g.,NODE_SKILLKEY_MISSING,TASK_NOT_FOUND)message: Human-readable messagecontext: Additional context (jobId, graphId, nodeId, etc.)
Task Not Found
When ai-tasks.runTask() returns "not found":
- Node is marked as
failedwithreason: "TASK_NOT_FOUND" - Includes
skillKeyand ai-tasks diagnostics payload as-is - Commits failure to graphenix (so history contains it)
- Then:
- If
failFast=true→ abort graph - Else continue if graph allows it
- If
Understanding Task Content
To avoid confusion across the stack:
- Node
skillKeyidentifies the task (ai-tasks layer) - The task will reference or embed a skill (ai-skills layer)
- If content is missing, it's either:
- Missing
node.skillKey→exellix-graph-engineerror (NODE_SKILLKEY_MISSING) - Task missing →
ai-tasksnot found (TASK_NOT_FOUND) - Skill missing inside task →
ai-tasks/ai-skillserror
- Missing
exellix-graph-engine surfaces these errors; it doesn't decide what tasks/skills exist.
Types
import type {
Graph,
GraphModelObject,
GraphAiModelConfig,
GraphModelAliasConfig,
GraphNode,
TaskNode,
TaskNodeRuntimeObject,
TaskNodeTaskConfiguration,
Job,
ExecuteGraphInput,
GraphExecutionRequest,
GraphRuntimeObject,
ExecuteGraphResult,
ExellixGraphRunTaskRequest,
ExellixGraphRunTaskResponse,
BuildAiTasksRunTaskRequestArgs,
ExecutionStepOption,
SmartInputConfig,
ExecutionStrategyInvocation,
XynthesizedMemory,
} from '@exellix/graph-engine';RunTask wire: ExellixGraphRunTaskRequest / ExellixGraphRunTaskResponse are aliases of @exellix/ai-tasks RunTaskRequest / RunTaskResponse. SmartInputConfig, ExecutionStrategyInvocation, XynthesizedMemory, and related strategy / xynthesized types are re-exported from @exellix/ai-tasks via src/types/aiTasksDerivedTypes.ts so you can import stable names from @exellix/graph-engine without duplicating NonNullable<RunTaskRequest[…]> aliases.
Error Codes
Structured errors are ExellixGraphError with ExellixGraphErrorCode:
import { ExellixGraphErrorCode } from '@exellix/graph-engine';
// Examples: JOB_ID_REQUIRED, NODE_SKILLKEY_MISSING, BACKWARD_GOAL_REQUIRED,
// GRAPH_LOAD_FAILED, GRAPH_EXECUTION_FAILED, NODE_EXECUTION_FAILED,
// TASK_NOT_FOUND, INVALID_GRAPH, INVALID_NODE, NON_CANONICAL_GRAPH_DOCUMENT,
// NON_CANONICAL_TASK_NODEValidation issues for smartInput / inputSynthesis during catalog planning use string codes such as SMART_INPUT_PATHS_INVALID, INPUT_SYNTHESIS_PIPELINE_CONFLICT, INPUT_SYNTHESIS_DESTINATION_INVALID (see src/inspection/validateAiTasksNodeExtensions.ts).
Repository Structure
src/
index.ts
runtime/ExellixGraphRuntime.ts
runtime/buildAiTasksRunTaskRequest.ts
runtime/aiTasksStrategyPhases.ts
runtime/graphRunExecutionSeed.ts
runtime/resolveExecutionPipelineForTaskNode.ts
runtime/localSkills/
runtime/variables.ts
runtime/memory.ts
runtime/events.ts
loaders/FileGraphLoader.ts
types/refs.ts
types/options.ts
types/aiTasksDerivedTypes.ts
types/results.ts
errors/ExellixGraphError.ts
errors/exellixGraphErrorCodes.tsTesting
npm test— Generic SDK checks only: deterministic finalizers, graph inspection, contract inspection (src/tests/run-tests.ts). Does not run bundled graph JSON fromgraphs/.npm run test:graphs— Question-breakdown sample graph (realai-tasks).npm run test:subnet—narrix-subnet-egress-triage.v1with sample data fromgraphs/tests/examples/.- Other graph runners (
dod,run:vuln-group:trace,test:web-scope-e2e, …) live undergraphs/tests/; see graphs/tests/README.md.
Fixtures and DOD outputs for those graphs are under graphs/tests/ (examples/, realdata/, outputs/). The top-level tests/ folder only documents the move (see tests/README.md).
Example graphs (graphs/)
Graph JSON shape: .docs/exellix-graph-engine-format.md.
Bundled graph definitions (JSON DAGs) live in graphs/. graphs/README.md is the bundle catalog (graph IDs, product-specific notes, execution call grid, appendix for bundled v2 graphs, and Platform contract: respected target for integration baseline and feature requests).
To execute a graph, supply a graphLoader that resolves graphId to JSON, build a job with the input shape your graph expects (often execution.input with raw and optional metadata), and call executeGraph. Further integration notes may live under .docs/.
taskConfiguration.aiTasksOutputValidation on task nodes is forwarded as outputValidation on the runTask request (@exellix/ai-tasks v7+). Root outputConstraints is not part of the closed schema.
Integration with Other Packages
See .reports/ directory for request documents:
graphenix-request-node-contract.md- Graph node contractai-tasks-request-not-found-contract.md- Task not found handlingai-tasks-request-variable-channels.md- Variable flow documentationexellix-helpers-requests.md- Helper utilities
License
ISC
