# @exellix/ai-tasks

> Task orchestration for the Exellix stack: runTask() with local handlers or LLM-backed execution, task-scoped memory/context enrichment, and executor dispatch via @exellix/ai-skills. ERC-compliant.

Latest version **10.17.1** (published 2026-07-13) · exellix-license license · 0 weekly downloads

## Install

```sh
npm install @exellix/ai-tasks
pnpm add @exellix/ai-tasks
yarn add @exellix/ai-tasks
bun add @exellix/ai-tasks
```

## Health

**Score 70/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 10.17.1 |
| Published | 2026-07-13 |
| First published | 2026-05-26 |
| Weekly downloads | 0 |
| License | exellix-license |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=20 |
| Dependencies | 24 |
| Unpacked size | 2.1 MB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Author | exellix |
| Maintainers | exellix |
| Keywords | ai, tasks, llm, skills, orchestration, erc, env-ready |

## Links

- npm: https://www.npmjs.com/package/@exellix/ai-tasks
- Repository: https://github.com/exellix/ai-tasks-mono-repo
- Homepage: https://github.com/exellix/ai-tasks-mono-repo#readme
- Issues: https://github.com/exellix/ai-tasks-mono-repo/issues
- npm.io page: https://npm.io/package/@exellix/ai-tasks

## Dependencies (24)

- [nx-cache](https://npm.io/package/nx-cache.md) ^1.0.2
- [handlebars](https://npm.io/package/handlebars.md) ^4.7.8
- [@x12i/funcx](https://npm.io/package/@x12i/funcx.md) ^4.10.4
- [@x12i/logxer](https://npm.io/package/@x12i/logxer.md) ^5.1.0
- [@x12i/activix](https://npm.io/package/@x12i/activix.md) ^9.0.6
- [@x12i/catalox](https://npm.io/package/@x12i/catalox.md) ^6.0.0
- [@x12i/rendrix](https://npm.io/package/@x12i/rendrix.md) ^4.3.0
- [@x12i/optimixer](https://npm.io/package/@x12i/optimixer.md) ^3.6.0
- [@x12i/ai-gateway](https://npm.io/package/@x12i/ai-gateway.md) ^11.1.4
- [@x12i/ai-profiles](https://npm.io/package/@x12i/ai-profiles.md) ^4.4.0
- [@exellix/ai-skills](https://npm.io/package/@exellix/ai-skills.md) ^7.2.2
- [@exellix/web-scope](https://npm.io/package/@exellix/web-scope.md) ^1.3.2
- [@exellix/xynthesis](https://npm.io/package/@exellix/xynthesis.md) ^4.9.14
- [@x12i/graphenix-core](https://npm.io/package/@x12i/graphenix-core.md) ^2.17.0
- [@exellix/fact-guard-types](https://npm.io/package/@exellix/fact-guard-types.md) ^1.0.1
- [@exellix/catalox-ai-skills](https://npm.io/package/@exellix/catalox-ai-skills.md) ^1.2.0
- [@x12i/graphenix-plan-format](https://npm.io/package/@x12i/graphenix-plan-format.md) ^2.17.0
- [@exellix/fact-guard-adapters](https://npm.io/package/@exellix/fact-guard-adapters.md) ^1.0.1
- [@exellix/fact-guard-policies](https://npm.io/package/@exellix/fact-guard-policies.md) ^1.0.1
- [@x12i/graphenix-trace-format](https://npm.io/package/@x12i/graphenix-trace-format.md) ^2.17.0
- [@exellix/fact-guard-enrichment](https://npm.io/package/@exellix/fact-guard-enrichment.md) ^1.0.1
- [@exellix/fact-guard-validation](https://npm.io/package/@exellix/fact-guard-validation.md) ^1.0.1
- [@x12i/execution-memory-manager](https://npm.io/package/@x12i/execution-memory-manager.md) ^1.2.0
- [@x12i/graphenix-executable-contracts](https://npm.io/package/@x12i/graphenix-executable-contracts.md) ^2.17.0

## Alternatives

- [@expo/fingerprint](https://npm.io/package/@expo/fingerprint.md) — 6.2M weekly downloads
- [@azure/monitor-opentelemetry-exporter](https://npm.io/package/@azure/monitor-opentelemetry-exporter.md) — 850.0K weekly downloads
- [@azure/monitor-opentelemetry](https://npm.io/package/@azure/monitor-opentelemetry.md) — 624.0K weekly downloads
- [@posthog/ai](https://npm.io/package/@posthog/ai.md) — 423.3K weekly downloads
- [fakefilter](https://npm.io/package/fakefilter.md) — 63.9K weekly downloads

## Recent versions

- 10.17.1 (latest) — 2026-07-13
- 10.17.0 — 2026-07-13
- 10.15.17 — 2026-07-12
- 10.15.16 — 2026-07-11
- 10.15.15 — 2026-07-10
- 10.15.14 — 2026-07-10
- 10.15.13 — 2026-07-10
- 10.15.12 — 2026-07-09
- 10.15.10 — 2026-07-08
- 10.15.8 — 2026-07-07
- 10.15.7 — 2026-07-07
- 10.15.5 — 2026-07-07
- 10.15.3 — 2026-07-07
- 10.15.2 — 2026-07-05
- 10.15.1 — 2026-07-05
- … 84 more at https://npm.io/package/@exellix/ai-tasks/versions

## README

# @exellix/ai-tasks

Private Git/npm package for executing **tasks** using the Exellix execution stack (`@exellix/ai-tasks` orchestrates `@exellix/ai-skills` MAIN + `@exellix/xynthesis` PRE/POST).

> **Catalox 6 train (in progress):** **`@exellix/xynthesis`** in this repo already uses **Catalox 6 / Mongo** locally (**4.9.13** prepared, not published). **`@exellix/ai-tasks`** and **`@exellix/catalox-ai-skills`** still use **Firebase** until **`@x12i/funcx@4.10.0`** ships and catalox-ai-skills migrates. **Phase 2** then aligns **`ai-skills-manager-ui`** and **`ai-tasks-playground-ui`**. Do not publish the stack until then. See [**`documenations/catalox-6-rollout.md`**](../documenations/catalox-6-rollout.md).

**Breaking — `executionStrategies` (required):** Every `runTask` request must include **`executionStrategies`**: an array of MAIN strategy invocations or **`[]`** for plain gateway MAIN. The old **`executionStrategyKey`** field is removed. See [BREAKING-CHANGES.md](BREAKING-CHANGES.md) and [RUNTASK_REQUEST.md](RUNTASK_REQUEST.md). Supported MAIN execution is exactly: **direct** via **`executionStrategies: []`**, **planner** before MAIN, **optimizer** after MAIN, or planner + optimizer together. **8.5+:** planner and optimizer run via **`@exellix/xynthesis`** sidekick actions (`execution-plan`, `execution-evaluate-result`) — not direct FuncX `run()` from ai-tasks. Catalog rows use `runtimeKind: "xynthesis-action"` and `defaultSidekickAction`.

### Graphenix node execution (10.x — graph-engine / Studio)

**`runTask()` requires a frozen `nodePlan`** (`planVersion: graphenix.node-plan/v2.1`). Graph-engine compiles authoring JSON once; ai-tasks does **not** accept `taskConfiguration` or `executionPipeline` on the wire.

| Layer | Units | Memory contract |
|-------|--------|-----------------|
| **Layer 1 (pre-SKILL)** | `webScope`, `narrativePreprocessor`, xynthesis `pre-actions` | Enrich `executionMemory` (`webContext`, `_narrix`, …) |
| **Layer 2 (in-SKILL)** | `synthesis` PRE → `plan`/`split` → `mainSkill` → `merge`/`pick-best` | Synthesis uses **`invokeContract.reads`** + optional `unitParams.memoryPaths` — **not** authoring `preInputStrategy` |

**Authoring:** `aiTaskStrategies.pre: "synthesis"` enables the context step only. Paths come from `inputsConfig` / `smartInput.paths` at compile time.

**Compiled plan (graphenix 2.1.0+):** synthesis unit must include non-empty `invokeContract.reads` and optional `unitParams.memoryPaths`. **`unitParams.synthesisInputStrategy` is forbidden** on the plan wire — validation error `LEGACY_SYNTHESIS_INPUT_STRATEGY_ON_PLAN`. The Catalox template key comes from **`supportedCapabilities.synthesisContext`** on the skill row only (`@exellix/ai-skills` **^6.11.0**).

**Dependencies (10.13+):** `@x12i/graphenix-executable-contracts` **^2.15.0**, `@exellix/web-scope` **^1.2.1**, `@exellix/ai-skills` **^7.0.2**, `@exellix/xynthesis` **^4.9.3**, `@x12i/funcx` **^4.9.20**, `@x12i/ai-gateway` **^11.0.0**, `@x12i/rendrix` **^4.3.0**, `@exellix/catalox-ai-skills` **^1.0.2**. Requires **Graphenix 2.15+** compiled `nodePlan` for synthesis/webScope IO.

**Synthesis memory (10.8+):** three template paths — `executionMemory.synthesizedContext.contextMarkdown` (job context), `executionMemory.synthesizedInput.sourceMaterial` (task input), `executionMemory.synthesizedWebContext.contextMarkdown` (web digest). `webScope` PRE runs forced synthesis to `synthesizedWebContext`. See [BREAKING-CHANGES.md](BREAKING-CHANGES.md#1080--synthesis-io-split--exellixweb-scope).

### Gateway server tools on MAIN (`llmRuntime`) — 10.5+

When a **`mainSkill`** unit includes Graphenix **`llmRuntime`**, ai-tasks forwards **`serverTools`**, **`openrouter`**, and **`serverToolInstructionMode`** to **`@exellix/ai-skills`** (never to `@x12i/ai-gateway` directly). Tool metadata returns on **`RunTaskResponse.metadata`** and optional top-level mirrors; optional **`toolMetadataPatch.execution.aiTooling`** when `llmRuntime.output.toolMetadata` is configured.

| Path | Source | Destination |
|------|--------|-------------|
| **webScope** (PRE) | `externalPreUtility` / `strategyKey: webScope` | `executionMemory.webContext`, `executionMemory.synthesizedWebContext` (forced synthesis) |
| **serverTools** (MAIN) | `mainSkill.llmRuntime.serverTools` | `response.metadata.*`, optional `toolMetadataPatch` |

Root **`RunTaskRequest.serverTools`** / **`openrouter`** are **forbidden** when `nodePlan` is set — use per-unit **`llmRuntime`** only. See **[documenations/gateway-server-tools.md](documenations/gateway-server-tools.md)**.

**Downstream handoff:** [graph-model-change-2026-06-13.md](../temp/downstream/graph-model-change-2026-06-13.md), [graphenix-format-alignment-fr.md](../temp/downstream/graphenix-format-alignment-fr.md), [graphenix-format-fact-guard-fr.md](../temp/downstream/graphenix-format-fact-guard-fr.md), [ai-engine-runtime-runtask-integration.md](../temp/downstream/ai-engine-runtime-runtask-integration.md). Wire fixture: `test/fixtures/run-task/graph-engine-pre-synthesis.json`.

**Breaking — 8.4+ LLM invoke contract (no legacy):** Every `runTask` requires **`modelConfig.preActionModel`**, **`modelConfig.skillModel`**, and **`modelConfig.postActionModel`**. Do **not** send `xynthesisModel`, root `modelConfig.model`, `maxTokens`, `maxTokensCap`, or graph-era tier names (`weak` / `strong`). Token budgets are owned by **Optimixer** in `@exellix/ai-skills` (MAIN) and `@exellix/xynthesis` (PRE/POST). Send **`outputExpectation`** (xynthesis) and optional **`reasoningEffort`**, **`temperature`**, **`topP`**. See [How completion budgets work](#how-completion-budgets-work-84) and [Model profile aliases](#model-profile-aliases-x12iai-profiles).

### Stack alignment (8.4+)

| Package | Role in `runTask` | Minimum (this repo) |
|---------|-------------------|---------------------|
| `@x12i/ai-profiles` | Profile/choice → wire model (`catalogLane` required on resolve) | **3.0.0** |
| `@exellix/xynthesis` | PRE synthesis, POST audit/polish, scoping, utilities, execution-strategy sidekick actions (`executeXynthesisAction` via `@x12i/funcx`) | **≥ 4.9.2** |
| `@exellix/fact-guard-*` | Optional sub-action atom integrity (PRE/POST only; disabled by default) | **^1.0.0** (monorepo: `file:`; see [`../fact-guard/RELEASE.md`](../fact-guard/RELEASE.md)) |
| `@exellix/ai-skills` | MAIN `runSkill` (Optimixer caps, gateway server-tools pass-through, provider failure taxonomy) | **≥ 7.0.1** |
| `@x12i/funcx` | PRE/POST sidekick built-ins, orchestration `run()`, logging (direct in ai-tasks; default invoker in xynthesis) | **≥ 4.9.20** |
| `@x12i/ai-gateway` | LLM wire for MAIN skill (transitive via ai-skills) | **≥ 11.0.0** |

**Gateway 11.x (via `@exellix/ai-skills` ≥ 7.0.2):** The gateway **owns billing** — read **`response.metadata.costUsd`**, **`costStatus`**, and token usage only; do **not** post-price with `@x12i/ai-tools`. Every invoke requires explicit **`model`** and **`maxTokens`** (Optimixer supplies `maxTokens` on MAIN before `gateway.invoke()`). Removed upstream: default model, flex-md token auto-fill, packaged instruction blocks, and the 500 ms between-call rate limit. On Activix rows, prefer root **`cost`** / **`costUsd`** and **`outer.cost`** over **`outer.metadata`** for billing. Failed invokes expose **`error.metadata`** (`GatewayInvokeRejectionMetadata` with **`providerSubCode`**, **`operatorHint`**, **`providerAttempts`** when the fallback chain exhausts — gateway **≥ 10.4.4**). Full migration rules: **[UPGRADING_AI_GATEWAY_10.md](../ai-skills/docs/UPGRADING_AI_GATEWAY_10.md)** in `@exellix/ai-skills`.

**Stored `modelConfig` slots** must use explicit **ai-profiles** **`profile/choice`** keys (`cheap/default`, `pro/default`, …). Bare profile names (`cheap`, `pro`), `profile@choice`, registry shortcuts, and legacy tier aliases (`weak` / `strong`) are **rejected** at validation — not expanded at invoke.

**Monorepo dev:** `npm test` syncs sibling **`xynthesis/dist`** and **`ai-skills/dist`** into `node_modules` before compile (see `scripts/ensure-xynthesis-dist.mjs` and `scripts/ensure-ai-skills-dist.mjs`).

**Execution model (10.x):** Every `runTask()` requires a frozen **`nodePlan`** (`graphenix.node-plan/v2.1`). See **[RUNTASK_REQUEST.md](RUNTASK_REQUEST.md)** for the authoritative wire contract and **[documenations/node-execution-graphenix.md](documenations/node-execution-graphenix.md)** for runtime behavior.

This package implements the canonical `runTask()` flow (every request carries **`executionStrategies`** — use **`[]`** for plain MAIN):

1. Validate **`nodePlan`** and reject forbidden legacy wire fields (`executionPipeline`, `taskConfiguration`, root `modelConfig`)
2. **if** a local task handler is registered for `skillKey` → run it and return
3. Loop **`nodePlan.executionUnits`** in order (PRE utilities, MAIN skill, POST utilities) via `executeNodeFromPlan`
4. Return **`RunTaskResponse`** with optional **`executionTrace`** events

Legacy root **`executionPipeline`**, **`executionType`**, and **`compileTaskConfigurationOnRunTaskRequest`** are **not** supported at runtime in 10.x.

### Template core and core-aware synthesis (additive)

`runTask` supports template-core-aware structured synthesis in PRE `synthesized-context`:

- Template content declares one or more core directives in Athenix `{{core:...}}` format (for example `{{core:analysis}}`); closed list: `question`, `action`, `plan`, `objective`, `decision`, `comparison`, `classification`, `evaluation`, `analysis`, `summary`, `generation`, `extraction`.
- `RunTaskRequest.taskCore` is removed from structured synthesis semantics; runtime derives cores from template declarations.
- Synthesis mode selection is additive:
  - preferred: `SynthesisConfig.synthesisMode: "markdown" | "structured"`
  - legacy-compatible: `SynthesisConfig.synthesisOutputFormat` still works.
- Structured mode uses detected `templateCores` + resolved question, then builds clean MAIN context from validated synthesized payload.
- Core discovery reads raw templates (instructions/prompt) via **`ExellixSkillsClient.resolveRawTemplate`** (templates loaded from the Catalox **`ai-skills`** catalog in `@exellix/ai-skills` 4.1+) before normal rendering; if no core directives are declared, structured synthesis is rejected as a template-definition error.
- Raw/enriched materials (`_narrix`, memory bundle, `webContext`) remain in memory; synthesized output is also stored at **`executionMemory.synthesizedContext`** for MAIN traceability unless a PRE step opts out via **`SynthesisConfig.xynthesizedOutput.alsoWriteLegacySynthesizedContext: false`** (see [Xynthesized memory and smart input](#xynthesized-memory-and-smart-input)).
- Optional **`RunTaskRequest.xynthesized`** holds **job-scoped**, **task-scoped**, and **execution-scoped** synthesized material for graph execution (distinct from raw memories). Optional **`smartInput`** matches **`SmartInputConfig`** from **`@exellix/ai-skills`** / Rendrix (**`paths`** as **`{ title, path, required? }[]`**). Callers may still send legacy **`paths: string[]`**; **`runTask`** validates and **normalizes** each string to **`{ title: path, path }`** before **`runSkill`**. Paths under **`xynthesized.*`** must use scope **`job`**, **`task`**, or **`execution`**; full path resolution for **`{{smartInput}}`** happens in the gateway/Rendrix stack. Optional **`smartInputRenderOptions`** is passed through like other **`RunSkillRequest`** fields.
- Backward compatibility is preserved by default: existing flows continue unchanged unless structured mode is explicitly selected.

Task responses may optionally include **`intermediateSteps`** when a task (or skill) runs multiple logical steps in one call (e.g. to-cni + enrich + triage); see [Intermediate steps (multi-step tasks)](#intermediate-steps-multi-step-tasks).

`@exellix/ai-tasks` depends on `@exellix/ai-skills` (MAIN) and `@exellix/xynthesis` (PRE/POST) and re-exports their catalog, invoke, and observability surfaces for single-package consumers.

### Gateway template rendering (v4)

Task execution goes through **`ExellixSkillsClient.runSkill`** (or the skills client’s executor), which uses **`gateway.invoke()`** — not `invokeChat()` — so instruction, prompt, and context templates are built via **`buildMessages`**, nx-content resolution, and **`@x12i/rendrix`** `render`. The gateway rejects a top-level **`input`** field on invoke requests; **`runSkill` / `runAudit`** map caller **`input`** (and related fields) into **`workingMemory.input`** (merged with `variables`, memories, object `context`, `knowledge`) so `.prompt` templates can use **`{{input}}`**.

On the **`runTask`** MAIN path, **`variables`** is the **job/graph bucket** forwarded **as-is** (align with **`executionMemory.jobVariables`**). **`executionMemory.taskVariables`** holds node scope. **`input`**, **`xynthesized`**, and **`smartInput`** are separate top-level fields — ai-tasks does **not** fold them into **`variables`**. Templates and Rendrix resolve **`jobVariables.*`**, **`taskVariables.*`**, **`xynthesized.*`**, and **`smartInput`** against the forwarded payload. Optional host helper **`mergeSkillTemplateVariables`** merges maps outside default MAIN.

**In this package:**

- **`runTask({ ... })`** accepts the same optional fields as **`RunSkillRequest`**: **`templateRenderOptions`**, **`templateTokens`**, **`smartInputRenderOptions`** (with **`smartInput`**). They are passed through unchanged to **`runSkill`** on the DIRECT / pipeline MAIN path.
- **Default parser options** for all skill runs: set **`templateRendering`** on **`ExellixSkillsClientOptions`** when constructing **`ExellixSkillsClient`** (or configure **`templateRendering`** on a gateway instance you pass as **`options.gateway`**). Per-call overrides use **`templateRenderOptions`** on the request.
- **Synthesis, audit, polish, and AI scoping** are executed via **`@exellix/xynthesis`** (and therefore inherit xynthesis behavior for retries/repair and diagnostics when enabled).

| Mechanism | Where | Effect |
|-----------|--------|--------|
| **`ExellixSkillsClientOptions.templateRendering`** | Client constructor | Sets gateway defaults when this package (or your app) constructs **`AIGateway`**. If you inject **`options.gateway`**, set **`templateRendering`** on that instance. |
| **`RunTaskRequest.templateRenderOptions`** | Per **`runTask`** | Deep-merged on gateway defaults for that invoke only. |
| **`RunTaskRequest.templateTokens`** | Per **`runTask`** | Passed through as gateway **`templateTokens`** (highest overlay priority during render). |

Types **`TemplateRenderOptions`**, **`SmartInputConfig`**, **`SmartInputRenderOptions`**, **`SmartInputRenderResult`**, **`RunTaskSmartInput`**, and **`GatewayTemplateTokens`** are re-exported from **`@exellix/ai-tasks`** (skill/gateway/Rendrix definitions). **`mergeSkillTemplateVariables`** is exported for callers that reproduce the same merge outside **`runTask`**. **`TaskRequestBuilder`** supports **`.withTemplateRenderOptions(...)`** and **`.withTemplateTokens(...)`**, plus **`.withXynthesized`**, **`.withSmartInput`**, **`.withSmartInputPaths`**, **`.withSmartInputRenderOptions(...)`**, **`.withXynthesizedJob`**, **`.withXynthesizedTask`**, **`.withXynthesizedExecution`** (see [Xynthesized memory and smart input](#xynthesized-memory-and-smart-input)).

**Errors:** **`TemplateResolutionError`** (or codes like **`TEMPLATE_RESOLUTION_ERROR`** / **`TEMPLATE_VARIABLE_MISSING`**) means a **required** template path was **`undefined`** after the gateway merged memory. Fix **`variables` / memories / input**, use optional fragments (**`{{path \|}}`**) in templates, or see the v4 guide for legacy **`silentMissingMustTokens`**. Template resolution failures are **not** treated as missing registry content (they do not go through **`RegistryManager.diagnose`** the same way as “content not found”).

Full protocol (MUST vs optional tokens, **`subPathSearch`**, errors): **[GATEWAY_TEMPLATE_PROTOCOL_V4.md](https://github.com/exellix/ai-skills/blob/main/GATEWAY_TEMPLATE_PROTOCOL_V4.md)** in **`@exellix/ai-skills`**. Nx-content layout: **`@x12i/ai-gateway`** [Content Resolver — Upstream Guide](https://github.com/athenices/ai-gateway/blob/main/CONTENT_RESOLVER_UPSTREAM_GUIDE.md).

---

## Install

**Packaged skills (recommended):** depend only on `@exellix/ai-tasks`; it bundles the execution stack and ships `.metadata` templates.

```bash
npm install @exellix/ai-tasks
```

**Custom gateway / advanced injection:** add `@exellix/ai-skills` and construct `WorexClientTasks` with your own client + executor.

```bash
npm install @exellix/ai-tasks @exellix/ai-skills
```

**Bundled x12i stack (direct dependencies of this package):** `@x12i/catalox` (catalog reads / publish script), `@x12i/env` (env conventions aligned with the Exellix stack), and `@x12i/logxer` **4.6.0+** — **`createLogxer`**, **`warnCode`/`errorCode`** with shipped diagnostic catalog (`.metadata/log-diagnostics.json`), **`DebugLogAbstract`**, **`fieldEvidence`**, **`runWithLogContext`** (wired in **`runTask()`** via **`runAiTasksWithLogContext`**), and in-process **`getAiTasksJobLogs`** for debug tooling. Per-package thresholds: **`AI_TASKS_LOGS_LEVEL`**, **`ACTIVIX_LOGS_LEVEL`**. Older names `logs-gateway` and `nx-config2` are not direct dependencies here.

### Narrix (v5+)

From **v5.0.0**, the local **`skills/skill.local:narrixRun`** path and **`narrixInput`** for **`narrix-then-direct`** require a unified shape: always set **`medium`** (`record` | `text` | `docs` | `chat`), **`datasetId`**, and the payload fields for that medium. Routing uses **`@exellix/narrix-runner` v2** (`runByQuestion` / `runByNarrative`) and **`@exellix/narrix-catalox`** for catalog hints—there is **no** `narrix-packs-library` or legacy “record job without `medium`” compatibility.

**Migration:** see **[BREAKING-CHANGES-NARRIX-V5.md](./BREAKING-CHANGES-NARRIX-V5.md)**. **Live Narrix tests:** `npm run test:narrix:live` (requires `.archive/packages/…` seed; see that doc).

---

## Catalox catalogs (upstream from this package)

`@exellix/ai-tasks` publishes **task-strategy** metadata to Catalox under **`appId: ai-tasks`**, and re-exports **read helpers** so apps can list catalogs, descriptors, and items **without adding `@x12i/catalox` as a direct dependency** (types such as `Catalox`, `CataloxContext`, `AppCatalogBootstrap` are re-exported from the task-strategies surface).

### AI Tasks catalogs

| Catalog ID | Role |
|-------------|------|
| **`ai-task-synthesis-input-strategies`** (alias **`ai-task-strategies-pre`**) | Synthesis **template** keys for `supportTemplates.synthesisContext` on the PRE step (memory scope is **`invokeContract.reads`** on `nodePlan`, not per-node authoring enums). |
| **`ai-task-pre-orchestration-strategies`** | PRE fan-out: `plan`, `split`. |
| **`ai-task-post-orchestration-strategies`** | POST fan-in: `merge`, `pick-best`, `audit-loop`. |
| **`ai-task-strategies-post`** | Post–core audit `selectionStrategy` (`best` / `synthesis`). |
| **`ai-task-input-strategies`** | Optional authoring hints: `inputStrategyKey` on `RunTaskRequest` (does not drive Narrix invocation). |
| **`execution-strategy`** | Preferred catalog for supported MAIN execution metadata: **`direct`**, **`planner`**, **`optimizer`**. |
| **`ai-task-execution-strategies`** | Compatibility MAIN wrappers metadata catalog: same seeded rows as **`execution-strategy`**. |
| **`ai-task-main-execution-wrappers`** | Compatibility MAIN execution-strategy catalog: same seeded rows as **`execution-strategy`**. |
| **`ai-task-narrix-modes`** | Narrix invocation: `narrixMode` (`off` \| `preprocessor` \| `handler`). |

Runtime **does not require** Catalox to be configured for these keys. Catalogs seed console metadata, and `execution-strategy` rows may be passed to `runTask` as `executionStrategyCatalogItems` for guarded metadata consumption. The runtime still code-validates supported strategy keys, phases, and retry rules; malformed or conflicting catalog rows are ignored. **Consumption order:** optional Narrix → optional web (preprocessor path) → optional Xynthesis **PRE** steps → **MAIN**.

### Supported MAIN Execution Strategies

The runtime supports three MAIN execution strategy records:

| Strategy | Request shape | Runtime behavior |
|----------|---------------|------------------|
| **`direct`** | **`executionStrategies: []`** | Plain MAIN. Calls `runSkill` once. `direct` is catalog metadata only and is **not** valid as a row inside `executionStrategies[]`. |
| **`planner`** | **`{ strategyKey: "planner", phase: "before", priority }`** | Runs xynthesis **`execution-plan`** before MAIN and can merge instructions, prompt, variables, and template tokens into the request. |
| **`optimizer`** | **`{ strategyKey: "optimizer", phase: "after", priority, maxIterations? }`** | Runs xynthesis **`execution-evaluate-result`** after MAIN. If not satisfied, retries MAIN with feedback until satisfied or `maxIterations` is reached. |

`planner` must use `phase: "before"` and `optimizer` must use `phase: "after"`. `optimizer.maxIterations` controls MAIN attempts; when omitted it falls back to `AI_TASKS_OPTIMIZER_MAX_ITERATIONS` or the package default. Per-invocation `args.functionId` always wins over catalog metadata and code defaults.

`execution-strategy` catalog items are structured metadata, not an open behavior plug-in. Safe runtime consumption is currently limited to fields explicitly marked in `safeRuntimeFields`, such as `defaultSidekickAction` / `runtimeKind: "xynthesis-action"`, and only when the catalog row matches the already-validated strategy key, phase, and request shape.

Legacy monolith id **`ai-task-strategies`** is no longer written by this repo; use the catalogs above.

### Publishing Firestore metadata

> **Note:** Task-strategy catalog publishing still uses **Firebase / Firestore** (`@x12i/catalox/firebase`) until the [**Catalox 6 rollout**](../documenations/catalox-6-rollout.md) completes for **`@exellix/catalox-ai-skills`**.

From a clone of this repo, with Firebase env in `.env` as required by **`createCataloxFromEnv()`** (see **`@exellix/ai-skills`**): **`FIREBASE_PROJECT_ID`** (required), **`GOOGLE_SERVICE_ACCOUNT_BASE64`** (required: service account JSON, base64-encoded), and optional **`FIRESTORE_DATABASE_ID`**:

```bash
npm run publish:task-strategies
```

That provisions **`apps/ai-tasks`**, all six native catalogs, bindings, descriptors, and canonical items.

### Reading catalogs and items (from `@exellix/ai-tasks`)

Construct a **`Catalox`** instance (for example `createCataloxFromEnv()` from `@exellix/ai-tasks`, which re-exports it from `@exellix/ai-skills`), then use any of:

| Export | Purpose |
|--------|---------|
| **`defaultAiTasksCataloxContext()`** | Build `CataloxContext` with `appId: "ai-tasks"`. |
| **`listAiTasksAppCatalogs(catalox, context?)`** | Discovery list: catalog id, label, access, source mode. |
| **`getAiTasksAppCatalogBootstrap(catalox, context?)`** | All **descriptors** visible to the app (identity, query fields, capabilities). |
| **`getAiTasksTaskStrategiesCatalogDescriptor(catalox, catalogId, context?)`** | One catalog’s **descriptor** (any of the six catalog ids above). |
| **`listPreCoreTaskStrategies` / `listPostCoreTaskStrategies`** | **Normalized** rows for pre / post synthesis–audit catalogs. |
| **`listInputStrategies` / `listExecutionStrategyCatalogItems` / `listExecutionStrategies` / `listMainExecutionWrappers` / `listNarrixModes`** | **Normalized** rows for input, preferred execution metadata, compatibility execution catalogs, MAIN wrappers, and Narrix mode catalogs. |
| **`getAiTasksTaskStrategiesCatalogSnapshot(catalox, context?, query?)`** | **All seven** task-strategy catalogs in one round-trip (`pre`, `post`, `input`, `executionStrategy`, `execution`, `mainExecutionWrappers`, `narrixModes`). |

Raw rows (full `UnifiedCatalogItem`) if you need them:

```typescript
import {
  createCataloxFromEnv,
  defaultAiTasksCataloxContext,
  AI_TASK_PRE_STRATEGIES_CATALOG_ID,
} from "@exellix/ai-tasks";

const catalox = createCataloxFromEnv();
const ctx = defaultAiTasksCataloxContext();
const { listOutcome, items } = await catalox.listCatalogItems(
  ctx,
  AI_TASK_PRE_STRATEGIES_CATALOG_ID,
  { limit: 100 }
);
```

### Skill templates (`ai-skills` app)

Skill definitions and template bodies for execution live under Catalox **`appId: ai-skills`** (owned by **`@exellix/ai-skills`**). This package re-exports the Catalox skill surface from **`@exellix/ai-skills`** via the same entrypoint (e.g. `listAiSkillsCatalogItems`, `fetchSkillTemplatesFromCatalox`, `createCataloxFromEnv`, `initFirebaseAdminFromEnv`, …) so you can **view and edit skill catalog items** while depending only on **`@exellix/ai-tasks`**.

---

## Usage

### Packaged skills (default client)

```typescript
import {
  createDefaultWorexClientTasks,
  ExecutionType,
} from "@exellix/ai-tasks";

const client = createDefaultWorexClientTasks();
// Optional before first runTask if nx-content needs time to connect:
await client.whenReady({ timeoutMs: 60_000 });

const result = await client.runTask({
  skillKey: "tasks/your-skill",
  agentId: "my-agent",
  jobTypeId: "my-job-type",
  taskTypeId: "my-task-type",
  executionStrategies: [],
  executionType: ExecutionType.DIRECT,
  input: { question: "..." },
  jobContext: { graphRunId: "..." },
});

await client.dispose();
```

---

## Utility tasks (non-question tasks)

ai-tasks supports **utility tasks**: tasks that run from **structured inputs** and **memory/context** to produce **structured, machine-consumable output**, without requiring a user-facing question.

- **Contract**: `RunTaskRequest.input` can be any JSON-compatible object (or string). **`input.question` is optional.**
- **Correlation (required):** every `runTask` request must include non-empty **`agentId`**, **`jobTypeId`**, and **`taskTypeId`** (same as `@exellix/ai-skills`). See [`RUNTASK_REQUEST.md`](RUNTASK_REQUEST.md) (including **Graph task node → RunTaskRequest** for authoring vs invoke).
- **When question matters**: Only skills/templates that *explicitly* reference `{{question}}` (or rely on question-driven synthesis) need a question. Utility tasks should not.
- **Output**: Utility tasks should typically return a structured `parsed` payload (for downstream graph nodes), not only prose in `rawText`.

Example (utility task with no question):

```typescript
import { runTask } from "@exellix/ai-tasks";

const res = await runTask({
  agentId: "my-agent",
  jobTypeId: "prepare-context-job",
  taskTypeId: "prepare-context-task",
  skillKey: "skills/graph.prepareContext",
  input: {
    record: { /* ... */ },
    policy: { /* ... */ },
  },
  executionMemory: {},
  jobMemory: {},
  graphId: "graph-123",
  nodeId: "prepare-context",
});

console.log(res.parsed);
```

Example (utility task as a local deterministic handler):

```typescript
import { runTask } from "@exellix/ai-tasks";

const res = await runTask({
  skillKey: "skills/skill.local:validateInput",
  input: {
    recordsPath: { $path: "executionMemory.inputs.records" },
    rules: { requirePaths: ["id"] },
  },
  executionMemory: { inputs: { records: [{ id: "a" }] } },
});

console.log(res.parsed); // { ok: true, meta: ... } (example shape)
```

### Structured decision output (`response.parsed`) + optional schema enforcement

For decision/utility nodes, prefer returning a stable object payload in `response.parsed` so graph `outputMapping` can reference fields deterministically (no markdown parsing).

Example decision task (built-in local handler):

```typescript
import { runTask } from "@exellix/ai-tasks";

const res = await runTask({
  skillKey: "skills/graph.decide-web-scope",
  input: { localEvidenceSufficient: true },
  taskKind: "decision",
  outputValidation: {
    schema: {
      type: "object",
      required: ["contractVersion", "shouldUseWeb", "reasonCodes", "missingSignals"],
      properties: {
        contractVersion: { type: "string" },
        shouldUseWeb: { type: "boolean" },
        reasonCodes: { type: "array", items: { type: "string" } },
        missingSignals: { type: "array", items: { type: "string" } },
      },
      additionalProperties: false,
    },
    mode: "fail",
  },
});

// Stable structured artifact for deterministic graph mapping:
// - res.parsed.shouldUseWeb
// - res.parsed.reasonCodes
// - res.parsed.missingSignals
```

`getPackagedAiTasksMetadataDir()` returns the absolute path to the shipped `.metadata` folder (override with `contentRegistryLocalPath` in client options). `forPackagedSkills` is an alias of `createDefaultWorexClientTasks`.

### Class-Based API (custom `ai-skills` client)

```typescript
import { ExellixSkillsClient, WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";
// `WorexClientSkills` is a type alias for `ExellixSkillsClient`

// skills client is the shared execution + helper layer
const skills = new ExellixSkillsClient({
  // ... your existing ai-skills config (gateway/provider/router)
});

// Get executor from skills client (no-enrichment execute primitive)
const executor = skills.getExecutor();

// tasks client orchestrates task-level execution
const tasks = new WorexClientTasks(skills, executor);

const res = await tasks.runTask({
  skillKey: "tasks/security-risk-summary",
  // executionType is optional, defaults to ExecutionType.DIRECT
  input: { assetId: "a-123", windowDays: 30 },

  // optional
  variables: { orgName: "Acme" },
  modelConfig: {
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "openai/gpt-5",
    temperature: 0.7,
    topP: 0.9,
  },
  jobId: "job-1",
  agentId: "agent-1",
});

console.log(res.rawContent);
console.log(res.parsed); // if your pipeline supports parsed output
```

### Function-Based API (Convenience)

```typescript
import { runTask, ExecutionType } from "@exellix/ai-tasks";

// Automatically initializes SDK (ERC mode)
// executionType is optional, defaults to ExecutionType.DIRECT
const res = await runTask({
  skillKey: "tasks/security-risk-summary",
  agentId: "agent-1",
  jobTypeId: "security-job-type",
  taskTypeId: "security-task-type",
  executionStrategies: [],
  input: { assetId: "a-123" },
  jobId: "job-1",
});
```

---

## RunTask Algorithm (Canonical)

Given a request, `runTask()` performs:

1. **Validate** `nodePlan` (`graphenix.node-plan/v2.1`) and reject forbidden wire fields. See **[RUNTASK_REQUEST.md](RUNTASK_REQUEST.md)**.
2. **Local task dispatch**: If `getLocalTask(skillKey)` returns a handler, run it and return (no LLM MAIN).
3. **Node execution**: `executeNodeFromPlan` loops ordered **`executionUnits`** (PRE utilities, MAIN skill, POST utilities), appending **`executionTrace`** events.
4. **Finalize response**: Attach observability, xynthesized patches, and trace metadata as configured.

Legacy steps involving root **`executionPipeline`**, **`executionType`**, or runtime **`taskConfiguration`** compile are **not** executed in 10.x.

### Trace mode (authoritative ordered debug trace)

Set `executionMode: "trace"` to opt in to an authoritative, ordered per-step execution trace. When enabled, the response includes:

- `debugTrace.tasks: DebugTraceTask[]`

Each entry represents a real unit of work that executed (pre steps, main skills/gateway call, post steps) and includes:

- `taskType`: `"pre-execution" | "ai-task" | "post-execution"`
- `details`: short human string
- `modelUsed`: string for LLM-backed steps; `null` for deterministic steps
- `metadata.timing`: `{ startedAt, endedAt, durationMs }`
- `metadata.step`: `{ phase, type, stepId }` (when applicable)

When upstream providers expose them (via `@exellix/xynthesis` / `@exellix/ai-skills` trace surfaces), trace tasks may also include usage/routing/cost under stable `metadata` keys.

**Graph / smart-input hints (ai-tasks–specific):** Trace entries for MAIN **direct** execution include **`metadata.smartInput: { paths }`** (echo of **`RunTaskRequest.smartInput.paths`** after normalization—**`{ title, path }[]`**, or empty array). PRE **`synthesized-context`** entries may include **`metadata.xynthesized`** (`inputPathsUsed`, `outputDestination`, `outputKey`, `patchKeys`) when **`SynthesisConfig.xynthesizedOutput`** is set—keys only, not full synthesized payloads.

---

## Xynthesized memory and smart input

Graph runtimes need structured execution context that is **not** the same as raw **`jobMemory` / `taskMemory` / `executionMemory`**: material synthesized once at job scope, scoped to a single node, or held in a run-wide **execution** bucket. **`RunTaskRequest`** supports:

| Field | Purpose |
|-------|---------|
| **`xynthesized?: { job?, task?, execution? }`** | **`job`** — synthesized context reusable across nodes in the same graph run (graph-engine merges patches from each **`runTask`**). **`task`** — scoped to this task/node invocation only. **`execution`** — run-wide execution bucket (distinct from job/task). Values are **`Record<string, unknown>`** buckets keyed by your **`outputKey`** (and any caller-supplied keys). |
| **`smartInput?: RunTaskSmartInput`** | Declares smart-input paths for Rendrix **`{{smartInput}}`** (via **`@exellix/ai-skills`** / gateway). Use Rendrix-native **`paths: { title, path, required? }[]`**, or legacy **`paths: string[]`** (normalized before **`runSkill`**). Paths under **`xynthesized.*`** must use **`job`**, **`task`**, or **`execution`** (e.g. **`xynthesized.execution.priorAnalysis`**). The **`smartInput`** object allows only the **`paths`** property at the root. Optional **`smartInputRenderOptions`** on **`RunTaskRequest`** controls markdown folding for the macro (same merge rules as **`RunSkillRequest`**). Full dot-path resolution against live memory happens downstream (Rendrix / gateway). |

### Validation (`smartInput`)

If **`smartInput`** is present, it must be a plain object with **`paths`** (array of non-empty strings or **`{ title, path, required? }`**) and optional **`strict`** (boolean). Invalid: **`smartInput: {}`**, bad **`paths`**, extra root keys, or **`xynthesized.<scope>.*`** with **`<scope>`** not **`job`**, **`task`**, or **`execution`**. Prefer path prefixes **`jobVariables.*`** and **`taskVariables.*`** (legacy **`variables.*`** ≡ job scope when the engine resolves paths). **`{ paths: [] }`** is valid.

At **`runTask()`** entry, invalid shape throws **`SmartInputValidationError`** (`error.code`, `error.details`, `error.skillKey`) — the task does not run (no NARRIX, no PRE xynthesis, no MAIN). Omitted **`smartInput`** is allowed.

### Pre-flight validation (no LLM invoke)

Use these helpers **before** `runTask()` to inspect config and payload without calling xynthesis or the gateway:

| Function | Purpose |
|----------|---------|
| **`validateRunTaskConfig(request)`** | Static checks: `agentId` / `jobTypeId` / `taskTypeId`, `executionStrategies`, `smartInput` shape, `modelConfig` / `llmCall` numeric fields, `executionPipeline` (one MAIN, synthesis PRE context flag). Returns `{ ok, issues, errors, warnings }`. |
| **`validateRunTaskInvoke({ request, templateResolver?, ... })`** | Config checks **plus** whether expected paths resolve on the request (`input`, memories, `xynthesized`). Uses Rendrix `renderSmartInput` for required smart-input paths and optional `analyzeTemplateResolution` on raw templates. |
| **`analyzeExpectedRunTaskInput({ skillKey, smartInput?, instructions?, prompt?, templateResolver? })`** | Returns expected dot-paths from **`smartInput.paths`** and path tokens in skill templates (Rendrix **`listTokens`**). |

```typescript
import {
  validateRunTaskConfig,
  validateRunTaskInvoke,
  analyzeExpectedRunTaskInput,
  SmartInputValidationError,
  isSmartInputValidationError,
} from "@exellix/ai-tasks";

// Config only (fast)
const configCheck = validateRunTaskConfig(runTaskRequest);
if (!configCheck.ok) {
  for (const issue of configCheck.errors) {
    console.error(issue.code, issue.path, issue.message);
  }
}

// Config + payload + templates
const invokeCheck = await validateRunTaskInvoke({
  request: runTaskRequest,
  templateResolver: {
    async resolveRawTemplate(skillKey, section) {
      const r = await skillsClient.resolveRawTemplate(skillKey, section);
      return r?.found ? r.content : undefined;
    },
  },
  checkTemplateResolution: true,
});
if (!invokeCheck.ok) {
  console.error(invokeCheck.errors);
}

// What paths does this node need?
const expected = await analyzeExpectedRunTaskInput({
  skillKey: runTaskRequest.skillKey,
  smartInput: runTaskRequest.smartInput,
  prompt: "...",
});
```

Each issue has **`code`**, **`severity`** (`error` \| `warning`), **`message`**, and optional **`path`** (e.g. `smartInput.paths[0].path`, `modelConfig.preActionModel`). See [`.docs/flow-io/`](../archived/ai-tasks/.docs/flow-io/flow-README.md) for flow-level wiring.

### Skill catalog helpers (runtime re-exports)

**`@exellix/ai-tasks`** re-exports Catalox-backed **catalog and execution helpers** from **`@exellix/ai-skills`** so orchestrators can depend on one package for **`runTask`** plus template maintenance:

| Export | Purpose |
|--------|---------|
| **`extractTokenNamesFromStrings`**, **`getSkillTokens`**, **`getSkillContent`**, **`modifySkillContent`**, **`createSubSkill`**, **`deleteSubSkill`**, **`SKILL_TEMPLATE_ROLES_ORDER`** | Template catalog introspection and edits (Catalox). |
| **`runSkillRequestFromFlat`**, **`resolveAiEngineIdForRunSkill`**, **`SkillExecutionTraceError`**, trace helpers | Flat skill execution and trace utilities. |

### Re-consider direct functions (not `runTask`)

Revision skills are **direct skill wrappers** from **`@exellix/ai-skills`**, re-exported on **`@exellix/ai-tasks`** for orchestrators that already depend on the task SDK. **Do not** invoke these via **`runTask`** — call them on **`ExellixSkillsClient`** or bind the standalone functions with a **`runSkill`** implementation.

| Export | Skill key | Result |
|--------|-----------|--------|
| **`fillReConsiderationGap`** | **`RECONSIDER_FILL_GAP_SKILL_KEY`** (`re-consider.fill-gap`) | `{ gapOutput }` |
| **`reviewReConsiderationResponse`** | **`RECONSIDER_REVIEW_SKILL_KEY`** | `{ reviewStatus, reviewOutput }` |
| **`rewriteReConsiderationScope`** | **`RECONSIDER_REWRITE_SCOPE_SKILL_KEY`** | `{ correctedScope }` |
| **`rewriteReConsiderationFull`** | **`RECONSIDER_REWRITE_FULL_SKILL_KEY`** | `{ correctedAnswer }` |
| **`calibrateReConsiderationDecision`** | **`RECONSIDER_CALIBRATE_DECISION_SKILL_KEY`** | `{ calibratedOutput }` |

Also exported: **`stringifyForPrompt`**, the five **`build*ReConsideration*RunSkillRequest`** builders, and types such as **`ReConsiderationRuntime`**, **`ReviewStatus`**, and **`ReConsiderationRunSkill`**. Shared runtime fields: `jobId`, `taskId`, `agentId`, `jobTypeId`, `taskTypeId`, `modelConfig`. Callers own merge/apply of returned fragments. See **`@exellix/ai-skills`** [README](../ai-skills/README.md#direct-functions-re-consider) for token roots and examples.

```typescript
import {
  ExellixSkillsClient,
  fillReConsiderationGap,
  RECONSIDER_FILL_GAP_SKILL_KEY,
} from "@exellix/ai-tasks";

const client = new ExellixSkillsClient(/* … */);
const result = await client.fillReConsiderationGap(
  {
    input: "What are the risks?",
    originalInstructions: "Answer with evidence.",
    originalResponse: priorAnswer,
    gapExplained: "Missing evidence for Section B.",
  },
  {
    jobId: "job-1",
    taskId: "task-1",
    agentId: "agent-1",
    jobTypeId: "job-type",
    taskTypeId: "task-type",
    modelConfig: { model: "gpt-4o-mini" },
  }
);
// Or: await fillReConsiderationGap.call({ runSkill: client.runSkill.bind(client) }, input, runtime);
```

**Studio preflight** (invoke packet review, markdown analysis reports, FuncX `ai-skills/analyze-gateway-invoke-request`) is **not** part of the runtime stack — use your **studio / skills-manager** layer. See **[`documenations/studio-skill-invoke-preflight.md`](documenations/studio-skill-invoke-preflight.md)** for a **suggested** design (requirements, former behavior, finding catalog, optional FuncX step). Also see **`@exellix/ai-skills`** [`SKILLS_MANAGER_CLIENT_GUIDE.md`](../ai-skills/docs/SKILLS_MANAGER_CLIENT_GUIDE.md).

Rendrix helpers used by validation are also re-exported: **`listTokens`**, **`analyzeTemplateResolution`**, **`renderSmartInput`**, **`SmartInputInvalidError`**, **`TemplateResolutionError`**, and related types.

### Runtime surfaces

- **Narrix preprocessor / Narrix handler `ctx`**: Same **`xynthesized`** / **`smartInput`** as on the **`runTask`** request after normalization (pipeline PRE runs later and can mutate **`xynthesized`** before MAIN).
- **PRE `synthesized-context`**: The bundle passed into template/rendering includes **`xynthesized`** and **`smartInput`** so synthesis prompts can reference them consistently with MAIN.
- **MAIN executor payload**: Top-level **`xynthesized`**, **`smartInput`** (normalized), and **`smartInputRenderOptions`** (when set) are forwarded on the object passed to **`runSkill`** (alongside memories and **`context`**).
- **Template variables**: **`variables`** (job bucket) and **`executionMemory.jobVariables`** / **`executionMemory.taskVariables`** are forwarded separately; use **`{{xynthesized…}}`**, **`{{smartInput}}`**, **`jobVariables.*`**, and **`taskVariables.*`** in templates (not a single flattened bag).
- **Response**: When downstream returns a **`SmartInputRenderResult`**, **`runTask`** **`finalize`** exposes **`response.smartInputRenderResult`** and mirrors **`metadata.smartInputRenderResult`** (also reads **`metadata.smartInput`** when it matches that shape).

### PRE synthesis destination (`SynthesisConfig.xynthesizedOutput`)

Optional on the **`synthesized-context`** step **`config`**:

- **`destination`**: **`"job"`** | **`"task"`** | **`"execution"`** — writes under **`request.xynthesized.job[outputKey]`**, **`.task[outputKey]`**, or **`.execution[outputKey]`** (no redirect to other scopes).
- **`outputKey`**: string key for the synthesized value.
- **`mode`**: **`"replace"`** (default) or **`"merge"`** — for merge, if the existing value at **`outputKey`** and the new value are both plain objects, ai-tasks **deep-merges** them; otherwise the new value replaces.
- **`alsoWriteLegacySynthesizedContext`**: When **`true`** or omitted, keep today’s behavior: persist structured/markdown artifacts on **`executionMemory.synthesizedContext`** (and **`synthesizedInput`** when applicable) for MAIN. When **`false`**, skip that legacy persistence for this PRE step; MAIN still receives synthesized **context markdown** from the PRE step when produced.

**Stored value:** Prefer the full synthesis **`artifact`** when present (structured / question-driven / markdown artifact shape). If there is no artifact, ai-tasks stores **`{ contextMarkdown }`** using the PRE step’s markdown string.

**Response:** **`RunTaskResponse.xynthesizedPatch`** has the same **`{ job?, task?, execution? }`** shape so the graph-engine can merge into durable graph memory. Omitted when nothing was written.

**Execution-scope example (PRE + smart-input):**

```typescript
executionPipeline: [
  {
    phase: "pre",
    type: "synthesized-context",
    config: {
      xynthesizedOutput: { destination: "execution", outputKey: "priorAnalysis" },
    },
  },
  { phase: "main", type: "direct" },
],
smartInput: { paths: ["xynthesized.execution.priorAnalysis"] },
// response.xynthesizedPatch.execution.priorAnalysis → graph-engine merges into executionMemory.xynthesized.execution
```

### Builder

**`TaskRequestBuilder`**: **`withXynthesized`**, **`withSmartInput`**, **`withSmartInputPaths`** (builds native **`{ title, path }`** entries), **`withSmartInputRenderOptions`**, **`withXynthesizedJob`**, **`withXynthesizedTask`**, **`withXynthesizedExecution`**.

### What ai-tasks does **not** do

Does **not** choose graph mappings, apply **`outputMapping`**, run the full graph, or own long-lived graph state—that belongs to the graph runtime. It **allowlists** **`xynthesized.*`** smart-input scopes (**`job`**, **`task`**, **`execution`**) but does **not** render **`{{smartInput}}`** or resolve paths against live memory (Rendrix / gateway).

---

## Local Tasks

Local tasks are **deterministic handlers** (no LLM) that run inside `runTask()` before any memory enrichment or executor call. They are identified by **skillKey**. If a handler is registered for that `skillKey`, it runs with the task input and context; the return value is wrapped into a standard `RunTaskResponse` (e.g. `parsed`, `metadata.durationMs`, `metadata.localSkillKey`).

### Built-in local tasks

| Skill key | Purpose |
|--------|--------|
| `skills/node.callExport` | Dynamically import a module, resolve an export (default or named), and call it. Supports `$path`-based argument resolution (e.g. `input.payload`, `jobMemory.foo`), optional job/global caching for instances. |
| `skills/node.callExportBatch` | Same module/export/cache semantics; creates or reuses a cached instance, then calls a **batch** method (e.g. `enrichJson`) with payload/options. Returns `{ ok, value, meta: { processed, errors, durationMs } }`. |
| `skills/skill.local:validateInput` | Validate records at a `$path` (e.g. `executionMemory.inputs.vulnInstances.records`). Rules: `requirePaths`, `graphTypeEquals`. No mutation; returns `{ ok, meta: { total, valid, invalid } }` or `{ ok: false, errors }`. |
| `skills/skill.local:normalizeNarrixResult` | Normalize records to a stable “Narrix attachment” shape: ensure `_narrix.meta.entity.entityKey`, `_narrix.scoping` / `_narrix.discovery` and their arrays, `joinCandidates`. Returns `{ ok: true, value: { records } }`. |
| `skills/skill.local:narrixRun` | Run one input through the Narrix pipeline (ingest → runner) for **record**, **text**, **docs**, or **chat**. Unified input: `medium` (`"record"` \| `"text"` \| `"docs"` \| `"chat"`) + `datasetId` + medium-specific payload. Returns `{ ok: true, entity, signals, stories, passes?, meta }` or `{ ok: false, error, message?, meta? }`. Legacy record shape (no `medium`) still supported. |
| `skills/graph.collectEvidence` | **Opt-in reference local handler**: collect evidence from structured requests (queries and/or URLs) and return a reusable **EvidenceBundle** for downstream graph nodes. No question required. |

---

## Generic evidence collection (`skills/graph.collectEvidence`)

ai-tasks standardizes a **generic, domain-agnostic** evidence collection contract for staged graph execution. This is **independent of NARRIX** question-driven web scoping: callers provide structured requests; the result is a reusable `EvidenceBundle` suitable for storing in `executionMemory` and reusing downstream.

- **Must-have**: a stable input/output contract graphs can depend on cleanly.
- **Reference handler**: ai-tasks ships an **opt-in local task** (`skills/graph.collectEvidence`) that implements the contract to reduce integration friction. Retrieval is **not** implicit; graphs opt in by selecting this `skillKey`.
- **Backends**: the reference handler may reuse existing internal search/fetch components, but the **public contract does not expose NARRIX semantics** (`narrix`, `webContext`, web-scope templates, etc.).
- **Types**: see `EvidenceCollectionInput` / `EvidenceBundle` exported from `@exellix/ai-tasks` (implemented in `src/types/evidence-types.ts`).
- **Current reference implementation notes**:
  - Supports **query-driven discovery** and **direct URL fetch**.
  - Enforces policy limits like `maxRequests`, `maxSourcesPerRequest`, `maxTotalSources`, domain allow/block lists, and snippet size caps.
  - Returns `value.sources[]` + per-request `value.requests[]`. If `extraction.returnFacts` / `extraction.returnSummary` are set, the reference handler currently returns empty `facts: []` / `summary: ""` (placeholders for future variants).

Recommended graph pattern:

```typescript
const evidenceRes = await runTask({
  skillKey: "skills/graph.collectEvidence",
  input: {
    requests: [
      { kind: "vendor_guidance", query: "ExampleProduct security advisory" },
      { kind: "exploitation_status", url: "https://example.com/advisory" },
    ],
    policy: { maxTotalSources: 10, includeSnippets: true },
  },
  executionMemory,
  jobMemory,
  graphId,
  nodeId,
});

// Store once, reuse later:
executionMemory.evidence = evidenceRes.parsed; // EvidenceBundle
```

### node.callExport (input contract)

- `module`: string (module name or path for dynamic `import()`)
- `export`: string (default `"default"`) — export name
- `call`: `{ method: string, args?: any[] }` — `method` can be `"function"` (call export as function), `"create"` (call and optionally cache), or any other string (call as method on cached/created instance)
- `cache`: optional `{ scope: "global" | "job", key: string }`
- Args may use `{ $path: "input.x" }` (or `jobMemory.*`, `taskMemory.*`, `executionMemory.*`, `variables.*`); resolved via dot-path before calling.

### node.callExportBatch (input contract)

- `module`, `export`, `cache`: same as above
- `call.create`: `{ method, args? }` — used to create/cache the instance
- `call.batch`: `{ method, args? }` — method and args for the batch call (args can use `$path`)
- `payload`, `options`: typically passed via `$path` in `call.batch.args` (e.g. `input.payload`, `input.options`)

### skills/skill.local:validateInput (input contract)

- `recordsPath`: `{ $path: "..." }` — path to records array (e.g. `executionMemory.inputs.vulnInstances.records`)
- `rules.requirePaths`: string[] — dot paths that must exist on each record
- `rules.graphTypeEquals`: optional string — require `graphized.meta.graphType` to equal this value
- `datasetId`: optional (for validation rules)

### skills/skill.local:normalizeNarrixResult (input contract)

- `recordsPath`: `{ $path: "..." }` — path to records array
- `attachRoot`: string (default `"_narrix"`) — key under which Narrix attachment lives
- `defaults.scopingPath`, `defaults.discoveryPath`: optional paths under attachRoot (default `"scoping"`, `"discovery"`)

### skills/skill.local:narrixRun (input contract)

One handler supports **four media**: record, text, docs, chat. Send a **unified input** with `medium` and `datasetId`, plus the payload for that medium. For full object shapes and use cases, see [NARRIX-FOUR-OBJECTS-AND-USE-CASES.md](./NARRIX-FOUR-OBJECTS-AND-USE-CASES.md).

**Unified input (recommended):**

- **Record:** `{ medium: "record", datasetId: string, record: Record<string, unknown> }`
- **Text:** `{ medium: "text", datasetId: string, text: string, meta?: object }`
- **Docs:** `{ medium: "docs", datasetId: string, document: { pages: Array<{ text, pageId?, pageNumber?, ... }>, docId?, title? } }`
- **Chat:** `{ medium: "chat", datasetId: string, thread: { messages: Array<{ role, text, ... }>, threadId?, title?, participants? } }`

**Success response:** `{ ok: true, entity, signals, stories, passes?, meta }` — `meta` includes `datasetId`, `packId`, `entityKind` (and run metadata).  
**Failure response:** `{ ok: false, error: string, message?: string, meta?: object }` — e.g. `NARRIX_DISABLED`, `NARRIX_INVALID_INPUT`, `RUNNER_PACK_NOT_FOUND`, or ingest/runner error codes.

**Feature flag:** Set **`USE_NARRIX_INGEST=1`** to enable the Narrix pipeline (use **`npm run test:with-narrix-ingest`** on Windows instead of prefixing the command). When unset, the handler returns `{ ok: false, error: "NARRIX_DISABLED" }` without calling ingest or runner.

**Debug logging:** Set `NARRIX_DEBUG=1` to log datasetId, medium, packId, entityKind, signal codes, and narrativeTypeIds on success (default: quiet).

See [NARRIX-STATUS-REPORT.md](./NARRIX-STATUS-REPORT.md) for implementation details and test layout.

### Custom local tasks

You can register your own handlers so they run when `skillKey` matches the registered key:

```typescript
import {
  registerLocalTask,
  getLocalTask,
  type LocalTaskContext,
  type LocalTaskHandler,
} from "@exellix/ai-tasks";

const myHandler: LocalTaskHandler = async ({ input, ctx }) => {
  // ctx: skillKey, jobMemory, taskMemory, executionMemory, variables, jobId, agentId, graphId, nodeId, ...
  return { ok: true, value: input.someField };
};

registerLocalTask("skills/my.custom.task", myHandler);

// Later: runTask({ skillKey: "skills/my.custom.task", input: { someField: 1 } }) will run myHandler and skip enrichment/executor
```

- **`registerLocalTask(skillKey: string, handler: LocalTaskHandler): void`** — register a handler for `skillKey`
- **`getLocalTask(skillKey: string): LocalTaskHandler | undefined`** — get handler (used internally by `runTask`)
- **`LocalTaskContext`** — `skillKey`, `jobMemory`, `taskMemory`, `executionMemory`, `variables`, `xynthesized`, `smartInput`, `jobId`, `taskId`, `agentId`, `graphId`, `nodeId`, `prevNodeId`, `coreSkillId`, `masterSkillId`, `masterSkillActivityId`
- **`LocalTaskHandler`** — `(args: { input: any; ctx: LocalTaskContext }) => Promise<any>`

### Execution tracing (metadata)

For graph runtimes (e.g. worex-graph), local task responses include consistent metadata so a trace can be stored per node:

- **`metadata.durationMs`** — time taken by the handler
- **`metadata.localSkillKey`** — `skillKey` that was run (e.g. `"skills/node.callExportBatch"`, `"skills/skill.local:validateInput"`)

Downstream can store e.g. `executionMemory._trace.nodes[nodeId]` with `taskKey`, `ok`, `durationMs`, `summary` (from `parsed.meta` when present).

**Exported API for local tasks:** `registerLocalTask`, `getLocalTask`, `registerBuiltInLocalTasks`, `LocalTaskContext`, `LocalTaskHandler` (see [API Reference](#api-reference) and Types).

---

## ExecutionType

`executionType` is **optional** on every request and defaults to `DIRECT` if not provided.

| Way | How you trigger it | What runs |
|-----|--------------------|-----------|
| **NARRIX pre-processor** | `request.narrix` set (e.g. from graph node metadata) | Resolve record → run NARRIX → inject `_narrix` into executionMemory/jobMemory; then run local task or DIRECT as below. |
| **Local task** | `skillKey` = local skill key (e.g. `skills/skill.local:narrixRun`) | Handler only; no enrichment, no LLM. |
| **Executor only** | Any other `skillKey`, `executionType` omitted or `"direct"` | Enrich → context → executor/LLM. No Narrix. |
| **Narrix then execute** | Same as executor, but `executionType: "narrix-then-direct"` + `narrixInput` | Narrix → append to `taskMemory.narrix` → then enrich → context → executor/LLM. |

Currently supported:

- `DIRECT` — single execution call after task-level enrichment/context (default)
- `NARRIX_THEN_DIRECT` (`"narrix-then-direct"`) — run Narrix first, inject result into task memory and context, then run the same DIRECT path; **requires** `narrixInput` (or resolution from job memory via `narrixInput.$path`). See [Narrix then execute](#narrix-then-execute) below for details.

Unsupported types throw an error with actionable context.

---

## Narrix then execute

Narrix processes **four input types** (record, text, docs, chat) and supports several **use cases** (local `skills/skill.local:narrixRun`, narrix-then-direct, normalize, validate). For the four input objects, their payload shapes, and detailed use cases, see **[NARRIX-FOUR-OBJECTS-AND-USE-CASES.md](./NARRIX-FOUR-OBJECTS-AND-USE-CASES.md)**.

Use **narrix-then-direct** when you want one `runTask()` call to scope data with Narrix (entity, signals, stories) and then run an LLM skill with that context—without calling `skills/skill.local:narrixRun` and then a second `runTask()` manually.

### Request

Same as a normal executor call, plus:

- **`executionType`**: `"narrix-then-direct"` or use the exported constant `NARRIX_THEN_DIRECT`.
- **`narrixInput`** (required): Either:
  - Full Narrix input: `{ medium, datasetId, record }` (or `text` / `document` / `thread` per medium). Same shape as the **skills/skill.local:narrixRun (input contract)** section elsewhere in this README.
  - Or `{ $path: "jobMemory.currentRecord" }` (or any `jobMemory.*` dot-path) to resolve from `request.jobMemory`.
- **`narrixScope`** (optional): Filters which signals and stories from Narrix are kept in task memory. Shape:
  - `includeSignals?: string[]` — keep only signals whose `code` is in this array.
  - `excludeSignals?: string[]` — drop signals whose `code` is in this array (ignored when `includeSignals` is set).
  - `includeStories?: string[]` — keep only stories whose `narrativeTypeId` is in this array.
  - `excludeStories?: string[]` — drop stories whose `narrativeTypeId` is in this array (ignored when `includeStories` is set).
  - When omitted, all signals and stories pass through unfiltered.
  - Example: `narrixScope: { includeSignals: ["OPEN_EGRESS_DETECTED", "PUBLIC_IP_EXPOSED"], includeStories: ["subnet-risk-summary"] }` — the task only sees these specific signals and narratives from the full Narrix output.

### Where Narrix output goes

- **Task memory:** `taskMemory.narrix` is an **array** of Narrix run outputs (`{ entity, signals, stories, meta }`). The executor and LLM receive this in the enriched memory and in the generated context (e.g. "## Narrix Scoping" section).
- **Convenience:** The latest run is also set on the skill input as `input.narrixContext` so prompt templates can reference it directly.
- **Response:** On success, `result.metadata.narrix` contains the last run's `{ entity, signals, stories, meta, durationMs }`.

### Minimal example

```typescript
import { runTask, NARRIX_THEN_DIRECT } from "@exellix/ai-tasks";

const result = await runTask({
  skillKey: "tasks/security-risk-summary",
  executionType: NARRIX_THEN_DIRECT,
  narrixInput: {
    medium: "record",
    datasetId: "subnetEgress",
    record: { graphized: { id: "subnet-1", cidr: "10.0.0.0/24" } },
  },
  input: { question: "Summarize the security posture of this subnet" },
  jobId: "job-1",
  agentId: "agent-1",
});

// result.parsed   → LLM output (from the skill)
// result.metadata.narrix → { entity, signals, stories, meta, durationMs } from Narrix
```

### Failure behavior

If Narrix fails (e.g. `ok: false`, or `NARRIX_DISABLED` when `USE_NARRIX_INGEST` is not set), the function returns early with `parsed: { ok: false, phase: "narrix", error, message }` and `metadata.narrix`; the executor/LLM is **not** called.

### Export

`NARRIX_THEN_DIRECT` is exported from `@exellix/ai-tasks` for use in `executionType`.

---

## NARRIX task-level pre-processor

When a task request includes a **`narrix`** config (e.g. from graph node **`taskConfiguration.narrix`**), ai-tasks runs the NARRIX enrichment pipeline **before** executing the task and injects the result so the task handler (or LLM) can use it. This supports "narrix serving graph": graph nodes are domain tasks (e.g. assess reachability, assess posture); NARRIX is configuration on the node that enriches context, not a separate pipeline exposed as graph steps.

### Request

Add optional **`narrix`** to `RunTaskRequest`:

- **`narrix.datasetId`** (required): e.g. `"network.egress.subnets"`, `"network.vuln.instances"`.
- **`narrix.attachToField`** (optional): field name on `executionMemory` for the attachment; default `"_narrix"`.
- **`narrix.engineConfigPath`**, **`narrix.packsRoot`**, **`narrix.deterministicSort`**, **`narrix.assumptionsPolicy`**: reserved for future use.

NARRIX builds the `_narrix` CNI attachment only. It does **not** trigger or configure web scoping — for web context add a standalone `webScope` PRE unit (below).

### Web scoping and upstream packages

Web scoping is implemented by **`@exellix/web-scope`**, which wraps **`@x12i/web-queries`** (query planning + **`WebContext`** mapping) through **`@x12i/web-scoper-memorix`** (TTL caching + synthesized-knowledge persistence) and **`@x12i/search-adapter`** (Tavily normalization).

The **only** trigger is a PRE unit with **`strategyKey: "webScope"`**. Its `unitParams`:

- **`webQueryTemplate`** (required when enabled): Rendrix string rendered into the search query, e.g. `"How to solve {{input.vulnerability}}?"`.
- **`webQueryTemplates`** (optional): `string[]` → each rendered into a **`searchMany`** pack.
- **`enableWebScope`** (default `true`): on/off switch.
- **`options`**: **`WebScopeOptions`** (`maxQueries`, `freshnessDays`, domain filters, `queryTemplates`, …).

Requires **`TAVILY_API_KEY`** for live search. The rendered template is validated at config time (`WEB_QUERY_TEMPLATE_REQUIRED` / `WEB_QUERY_TEMPLATE_UNRESOLVED`); there is no `input.question` fallback. Playground input is record/facts only, referenced by the template via `{{input.*}}`.

Each **`WebContext.sources[]`** entry includes **`url`**, **`domain`**, **`snippet`**, **`retrievalStage`**, and **`meta.discoveredCount`**. Snippet/fetch behavior follows **`WebScoperConfig`** defaults in `@x12i/web-queries` (see that package README for caps and page fetch). Successful **`webScope`** units also trigger forced web synthesis and optional Memorix persistence (`MONGO_URI`, `EXELLIX_NAME`, TTL vars).

**Downstream LLM context:** On the DIRECT path with **`includeContextInPrompt`**, when Narrix output is in play, ai-tasks appends a markdown section from **`executionMemory.webContext`** (when `ok === true`) so the model sees source bodies, not only NARRIX signal codes. The **`synthesized-context`** PRE step uses the same markdown builder for policies that include web (e.g. **`narrix-web`**, **`narrix-web-memory`**, **`memory-web`**, **`auto`** when a web hit exists). Optional env **`WEB_CONTEXT_MARKDOWN_MAX_CHARS`** and optional **`SynthesisConfig.webEvidence`** cap and shape that markdown. Serialized memory for synthesis **never** embeds raw **`webContext`** as JSON; web appears as markdown only when the policy includes web.

Technical flow, failure behavior, and consumption patterns: **[documenations/web-scoping-in-ai-tasks.md](./documenations/web-scoping-in-ai-tasks.md)**.

### Raw record resolution

The pre-processor needs a **raw record** to enrich. Resolution order:

1. `executionMemory.input.raw`
2. `executionMemory.inputs.<first key>.raw`
3. `jobMemory.record` or `jobMemory.currentRecord`
4. `input.record` or `input.raw`

If no record is found, `runTask` throws with a clear error. **sourceMeta** is taken from `executionMemory.input.sourceMeta` or `jobMemory.sourceMeta` or `{}`.

### Where the result goes

- **Canonical:** `executionMemory[attachToField]` (default `executionMemory._narrix`) — shape `{ scoping: { stories, signals }, discovery: { stories, signals }, meta }`.
- **Mirrored:** `jobMemory._narrix` — same attachment so handlers can read from either `ctx.executionMemory._narrix` or `ctx.jobMemory._narrix`.
- **Web scope:** when a **`webScope`** PRE unit runs, **`executionMemory.webContext`** is set to the **`WebScopeResult`** / **`WebScopePackResult`** from **`@exellix/web-scope`** (`ok: true` hit or structured miss/error). Successful scoping also runs web synthesis into **`executionMemory.synthesizedContext`**. Independent of **`_narrix`**.

Local task handlers receive the enriched `ctx`. On the DIRECT path, context is added only when `includeContextInPrompt === true`. When NARRIX is in play, that context is the "## Scoping and discovery" section from `executionMemory._narrix` when present, plus a **Web sources (primary evidence)** section when web scoping returned **`webContext.ok === true`** (see above). We do not inject a "Narrix Pre-Processor" placeholder. The context generator (xontext) is not given _narrix when NARRIX is in play, so it cannot emit that section.

### Per-node enrichment

Each `runTask` call with `narrix` set is independent. If a graph runs two nodes with narrix config, each gets its own NARRIX run (no shared state). Orchestrators (e.g. graph-engine) lift `node.taskConfiguration.narrix` to `RunTaskRequest.narrix`.

### Example

```typescript
await runTask({
  skillKey: "tasks/assess-reachability",
  narrix: { datasetId: "network.egress.subnets" },
  executionMemory: { input: { raw: subnetRecord } },
  input: { question: "Assess reachability based on scoping." },
});

// Handler or LLM sees ctx.executionMemory._narrix (scoping, discovery, meta)
```

---

## Intermediate steps (multi-step tasks)

When a task runs multiple logical steps in one invocation (e.g. a combined node that does "to-cni + enrich + triage"), the response can include an optional **`intermediateSteps`** array so consumers can inspect and report the chain without extra round-trips.

### Response shape

- **`intermediateSteps`** (optional): array of steps in execution order. Each step has:
  - **`step`** (number): 1-based order
  - **`id`** (string): stable identifier (e.g. `"to-cni"`, `"engine-enrich"`, `"triage-q1-q6"`)
  - **`ok`** (boolean): whether the step succeeded
  - **`summary`** (optional): short description (e.g. `"cni built"`, `"enriched"`)
  - **`error`** (optional): error message when `ok` is false
  - **`inputSummary`** (optional): summary input for reporting
  - **`outputExcerpt`** (optional): small excerpt of step output for debugging

Skills/tasks can return steps **inside `parsed`** (e.g. `parsed.intermediateSteps`); the SDK lifts them to the top-level **`response.intermediateSteps`** so consumers always read from the same place. Local tasks can return `{ output: {...}, intermediateSteps: [...] }`; the SDK exposes `intermediateSteps` on the response and keeps only the rest in `parsed`. For **narrix-then-direct**, the runtime prepends a step `{ step: 1, id: "narrix", ok: true }` and renumbers any steps from the direct phase.

### Example

```typescript
const res = await runTask({ skillKey: "tasks/combined-cni-enrich-triage", input: { ... } });

if (res.intermediateSteps) {
  for (const s of res.intermediateSteps) {
    console.log(`Step ${s.step}: ${s.id} ${s.ok ? "ok" : s.error}`);
  }
}
// res.parsed holds the final task output (intermediateSteps are not duplicated there)
```

### Execution pipeline and synthesized-context (PRE step)

**Breaking change:** Execution can use an **execution pipeline** instead of a single `executionType`. See [BREAKING-CHANGES.md](BREAKING-CHANGES.md) for migration from `executionType` to `executionPipeline`.

- **Pipeline:** `executionPipeline` is an array of steps with phases **pre**, **main**, and **post**. Order: all PRE steps (in order) → exactly one MAIN step → all POST steps (optional; built-in types: `audit`, `polish`). Default when omitted: `[{ phase: "main", type: "direct" }]`. Existing `executionType` (e.g. `DIRECT`, `NARRIX_THEN_DIRECT`) is still supported when `executionPipeline` is not set.
- **PRE step `synthesized-context`:** A weak model (default `gpt-5-nano`) synthesizes **source material** (Narrix / memory / web per policy below) into a single context string; the MAIN task then runs with that string as its `context`. Requires `includeContextInPrompt: true` (or the PRE step’s `config.autoEnableContext: true`). Add `{ phase: "pre", type: SYNTHESIZED_CONTEXT, config: SynthesisConfig }` before the main step (`SYNTHESIZED_CONTEXT` is exported as the string `"synthesized-context"`).
- **Two LLM calls (typical):** deterministic **source material** (policy + markdown/JSON assembly) → **synthesis** model → synthesized `context` → **main** task model. Synthesis is not a pure string merge; it is a separate gateway invocation before the main skill.
- **SynthesisConfig** (on the PRE step’s `config`): `modelConfig`, **`contextSourcePolicy`**, optional **`synthesisInputStrategy`** (`policy` / `execution-memory-only` / `job-memory-only` / `task-memory-only` / `full-memory-bundle`), optional **`webEvidence`**, `customSynthesizingGuidelines`, `fallbackToDirect` (default `false`), `memoryPaths`, `synthesisPromptOverride`, `timeoutMs`, `maxOutputLength`, `autoEnableContext`, optional mode selectors **`synthesisMode`** (preferred) and **`synthesisOutputFormat`** (legacy-compatible), plus optional **`questionPath`**, **`getQuestion`**, **`structuredMaxItemsPerSide`**, **`structuredMaxItemContentChars`**, plus optional **`questionDriven`** and **`questions`**, plus optional **`xynthesizedOutput`** (**`destination`**: **`"job"`** | **`"task"`** | **`"execution"`**, **`outputKey`**, **`mode`**, **`alsoWriteLegacySynthesizedContext`**) to mirror synthesis into **`request.xynthesized`** and return **`response.xynthesizedPatch`** (see [Xynthesized memory and smart input](#xynthesized-memory-and-smart-input)). Full API: [documenations/synthesized-context-guide.md](./documenations/synthesized-context-guide.md).
- **Question-driven synthesis (opt-in):** Set **`questionDriven: true`** and provide **`questions`** (`[{ id, question, source?: "record"|"web"|"both" }]`). This mode runs one synthesis per question and writes a structured artifact to `executionMemory.synthesizedContext` with `mode: "questionDriven"` and stable access path `executionMemory.synthesizedContext.answers.<id>.synthesis`. Web-only questions degrade gracefully when `executionMemory.webContext` is unavailable (answer is present but marked unknown + reason).
- **Structured synthesis (opt-in):** Set **`synthesisMode: "structured"`** (or legacy-compatible **`synthesisOutputFormat: "structured"`**) so the synthesis model returns **JSON** matching **`SynthesizedPromptPayload`** (`SynthesisInput`, `SynthesizedContext`, `SynthesizedItem`, etc., exported from **`@exellix/ai-tasks`**). The package validates the shape, then builds main-step **`context`** markdown via **`buildSynthesizedContextMarkdown`** (task cores → question → local → supporting → unknowns → assumptions). Raw local vs web blocks are passed as **`local_raw`** / **`supporting_raw`** in the synthesis prompt (see **`resolveSourceMaterialParts`**). The task **question** is taken from **`getQuestion(request)`**, else **`questionPath`** on **`request.input`** (default path **`question`**), else the rendered downstream user prompt (truncated). In structured mode, template core tokens are required in template content; no detected template cores is treated as a template-definition error. Structured artifacts are persisted at `executionMemory.synthesizedContext`. Templates: **`templates/synthesis/system-structured.md`** and **`user-structured.txt`** (override path still **`SYNTHESIS_TEMPLATES_PATH`**). For tests or custom execution, use **`setContextSynthesizer`** / **`getContextSynthesizer`**; **`runStructuredSynthesisGatewayCall`** mirrors the default gateway path.
- **Synthesis uses a real LLM call** (`AIGateway.invoke` via xynthesis `executeXynthesisAction`) — not just string merging. Building `source_material` is deterministic; the PRE step then **invokes the synthesis model** (e.g. `SYNTHESIS_MODEL` / `modelConfig`) before the MAIN step runs. See [documenations/synthesis-invocation-notes.md](./documenations/synthesis-invocation-notes.md) for details.
- **`webEvidence`:** Optional tuning for the **Web sources** markdown block inside synthesis source material: `preferCleanContent` (default `true`), `maxSources` (default `5`), `dedupeByUrl` (default `true`), `maxTotalChars`. Env **`WEB_CONTEXT_MARKDOWN_MAX_CHARS`** still applies when `maxTotalChars` is not set.
- **`contextSourcePolicy` (what becomes `<source_material>` in the synthesis prompt):**

| Policy | Narrix section | Web markdown | Memory JSON |
|--------|----------------|--------------|-------------|
| **`narrix-web`** | Yes (required) | If `webContext` hit | No |
| **`narrix-web-memory`** | If present | If hit | Yes — never embeds raw `webContext` in JSON |
| **`memory-web`** | **Stripped** from JSON | If hit | Yes |
| **`memory-only`** | No | No | Yes — never embeds raw `webContext` in JSON |
| **`auto`** | Resolves at runtime | | |

**`auto` resolution:** **`narrix-web-memory`** if Narrix output exists (`executionMemory[attachToField]` or coerced **`taskMemory.narrix`**); else **`memory-web`** if web scoping produced a usable hit; else **`memory-only`**. **Legacy aliases:** `narrix-only` → `narrix-web`, `narrix+memory` → `narrix-web-memory`.

**Synthesis output shape (default):** `templates/synthesis/system.md` asks the weak model for three markdown sections in order: **`## Local context (primary)`**, **`## Web evidence (supporting)`**, **`## Supporting sources (optional)`** (short traceability lines only). Override with `synthesisPromptOverride` if needed. With **`synthesisOutputFormat: "structured"`**, output is JSON validated to the generic synthesis contract, then converted to markdown for the main step only.

**Migration note:** If you relied on **`auto`** with Narrix but **without** memory in the synthesizer input, set **`contextSourcePolicy: "narrix-web"`** explicitly. See [BREAKING-CHANGES.md](BREAKING-CHANGES.md) (*Synthesis `contextSourcePolicy` and `auto`*).
- **Templates:** Synthesis prompts live in **templates/synthesis/** — markdown mode: `system.md`, `user.txt`; structured mode: `system-structured.md`, `user-structured.txt`. Optional env **`SYNTHESIS_TEMPLATES_PATH`** for base path.
- **Caching:** Optional synthesis result caching via **nx-cache** (same project) can be used to reduce cost for batch or repeated runs.
- **Non-streaming:** Synthesis returns a **single complete response**; there is no streaming support for the synthesis step.
- **Builder:** `withExecutionPipeline(steps)`, `withSynthesizedContextPreStep(modelOrConfig?)` to add the PRE step and set `includeContextInPrompt: true` (pass a **`SynthesisConfig`** object for `contextSourcePolicy`, `webEvidence`, `memoryPaths`, **`xynthesizedOutput`** with **`destination: "job" | "task" | "execution"`**, etc.). Also **`withXynthesized`**, **`withSmartInput`**, **`withSmartInputPaths`**, **`withSmartInputRenderOptions`**, **`withXynthesizedJob`**, **`withXynthesizedTask`**, **`withXynthesizedExecution`** for request-level fields.
- **Auditability fields in response metadata:** `synthesisEnabled`, `effectiveExecutionPipeline`, `synthesizedContextPresent`, `mainContextSource`, `detectedTemplateCores`. Final response also includes normalized `executionMemory` and `executionState.executionMemory`.

**Minimal example (explicit policy + web tuning):**

```typescript
import { SYNTHESIZED_CONTEXT } from "@exellix/ai-tasks";

executionPipeline: [
  {
    phase: "pre",
    type: SYNTHESIZED_CONTEXT,
    config: {
      contextSourcePolicy: "narrix-web-memory",
      webEvidence: { maxSources: 3, preferCleanContent: true },
      modelConfig: { model: "gpt-5-nano" },
    },
  },
  { phase: "main", type: "direct" },
],
includeContextInPrompt: true,
```

### POST Step Strategies (post-execution)

The execution pipeline supports **POST** steps that run after the MAIN step and operate on its response. Two built-in strategies: **`audit`** (quality-gate loop with re-runs) and **`polish`** (refinement pass against a prioritized checklist). POST steps run in pipeline order; each step’s output becomes the next step’s input.

**Pipeline example:**

```typescript
executionPipeline: [
  { phase: "pre",  type: "synthesized-context", config: { /* ... */ } },
  { phase: "main", type: "direct" },
  { phase: "post", type: "audit",  config: { gateway: { must: [...], should: [...] }, maxCycles: 3 } },
  { phase: "post", type: "polish", config: { checklist: [...], maxPasses: 2 } },
]
```

Metadata for each POST step is under `response.metadata.postSteps.<type>`. All POST steps’ intermediate steps are merged into `response.intermediateSteps` with continuous numbering (e.g. `audit-cycle-1`, `audit-synthesis`, `audit-select`, `polish-pass-1`).

### Fact-guard (optional sub-action integrity)

Optional atom integrity checks for **sub-actions only** (PRE synthesis, POST audit/polish/scoping, execution-strategy planner/optimizer). **MAIN `runSkill` is never guarded.** Requires **`@exellix/fact-guard-*` ^1.0.0** (transitive via this package).

| When | Behavior |
|------|----------|
| `factGuard` absent or `enabled: false` | Identical to pre–fact-guard behavior |
| `FACT_GUARD_DISABLED=1` | Emergency kill-switch — all guards bypassed |

**Config attachment:**

| Step | Field |
|------|--------|
| PRE `synthesized-context` | `SynthesisConfig.factGuard` |
| POST `audit` | `AuditConfig.factGuard.audit` / `.synthesis` (merge) |
| POST `polish` | `PolishConfig.factGuard` |
| AI scoping | `RunTaskRequest.aiScopingOptions.factGuard` |
| Execution strategy | `ExecutionStrategyInvocation.factGuard` |

```json
{
  "enabled": true,
  "policyId": "prompt-context-synthesizer",
  "onViolation": "fallback",
  "maxRetries": 2,
  "onExhausted": "fallback"
}
```

Violations appear in structured logs (`fact_guard.sub_action`) and optionally on **`response.metadata.factGuard[stage]`** (trace mode). See [`documenations/synthesized-context-guide.md`](documenations/synthesized-context-guide.md) and [`../fact-guard/README.md`](../fact-guard/README.md).

**Tests:** `npm run test:fact-guard` (mocked LLM; no API key required).

#### POST step: `audit`

Quality-gate loop: evaluate MAIN output against **must** and **should** checks, get actionable feedback, re-run the MAIN step when criteria aren’t met, then choose the best result (or run an optional synthesis merge of the top two candidates).

- **Config:** `AuditConfig`: `gateway.must`, `gateway.should` (each `{ check, weight }`), `threshold` (default 80), `minCycles`, `maxCycles` (default 3), `selectionStrategy` (`"best"` | `"synthesis"`), `auditModelConfig`, `synthesisModelConfig`, `customAuditGuidelines`, `customSynthesisGuidelines`, `fallbackToBest` (default true), `auditTimeoutMs`.
- **Output:** Audit uses **structured Markdown** (Flex-MD style); the runtime parses it into checks and overall feedback and computes `weightedScore`.
- **Re-runs:** When the loop continues, the MAIN step is re-invoked with the original context plus a feedback block from `templates/post-steps/audit/feedback-injection.md`.
- **Metadata:** `response.metadata.postSteps.audit`: `totalCycles`, `candidateScores`, `selectedCycleIndex`, `allMustPassed`, `synthesisUsed`, `auditResults`, `durationMs`.
- **Caching:** Audit (and synthesis) LLM calls use **nx-cache** when inputs and config are unchanged (no cache on re-runs with feedback).
- **Failure:** If all cycles are exhausted and no candidate passes all `must` checks: with `fallbackToBest: true` (default) the best-scored candidate is returned and `allMustPassed: false`; with `fallbackToBest: false` an error is thrown.

#### POST step: `polish`

Single- or multi-pass refinement against a **prioritized checklist**. Does not re-run MAIN; always produces a refined output (or falls back to the last good output on failure).

- **Config:** `PolishConfig`: `checklist` (each `{ instruction, priority: "high" | "medium" | "low" }`), `maxPasses` (default 1), `modelConfig`, `customGuidelines`, `includeOriginalContext`, `timeoutMs`.
- **Output:** Polish LLM returns **JSON**: `{ polishedOutput, changeNotes[] }`.
- **Metadata:** `response.metadata.postSteps.polish`: `totalPasses`, `changeNotes` (per pass), `durationMs`.
- **Failure:** Polish never throws; on LLM/parse failure it returns the previous output and records `ok: false` in the step.

#### Minimal examples

**Audit only:**

```typescript
const result = await runTask({
  skillKey: "tasks/security-risk-summary",
  input: { assetId: "a-123" },
  jobId: "job-1",
  agentId: "agent-1",
  executionPipeline: [
    { phase: "main", type: "direct" },
    {
      phase: "post",
      type: "audit",
      config: {
        gateway: {
          must: [{ check: "Output includes a severity rating (critical/high/medium/low)", weight: 10 }],
          should: [{ check: "Recommendations are ordered by priority", weight: 5 }],
        },
        threshold: 80,
        maxCycles: 3,
      },
    },
  ],
});
console.log(result.parsed);                    // best candidate output
console.log(result.metadata.postSteps?.audit); // totalCycles, candidateScores, allMustPassed, etc.
```

**Polish only:**

```typescript
const result = await runTask({
  skillKey: "tasks/executive-summary",
  input: { report: "..." },
  executionPipeline: [
    { phase: "main", type: "direct" },
    {
      phase: "post",
      type: "polish",
      config: {
        checklist: [
          { instruction: "Remove jargon; use plain language", priority: "high" },
          { instruction: "Each paragraph ≤ 3 sentences", priority: "medium" },
        ],
        maxPasses: 2,
      },
    },
  ],
});
```

#### Chaining audit + polish

Common pattern: audit first to ensure quality, then polish to refine the approved candidate. Audit’s chosen output becomes polish’s input.

#### Builder

- **`withAuditPostStep(config: AuditConfig)`** — Appends an audit POST step; ensures pipeline has a MAIN step.
- **`withPolishPostStep(config: PolishConfig)`** — Appends a polish POST step; ensures pipeline has a MAIN step.

Example with builder (no need to set `executionPipeline` manually):

```typescript
const request = new TaskRequestBuilder()
  .withSkillKey("tasks/security-risk-summary")
  .withInput({ assetId: "a-123" })
  .withJobId("job-1")
  .withAgentId("agent-1")
  .withAuditPostStep({
    gateway: {
      must: [{ check: "Output includes severity rating", weight: 10 }],
      should: [{ check: "Recommendations ordered by priority", weight: 5 }],
    },
    threshold: 80,
    maxCycles: 3,
  })
  .withPolishPostStep({
    checklist: [
      { instruction: "Use active voice throughout", priority: "high" },
      { instruction: "Remove redundant qualifiers", priority: "medium" },
    ],
  })
  .build();
const result = await runTask(request);
```

#### Template paths

- Audit: `templates/post-steps/audit/` (`system.md`, `user.txt`, `feedback-injection.md`, `synthesis.md`). Override: `AUDIT_TEMPLATES_PATH` (base path for the audit folder).
- Polish: `templates/post-steps/polish/` (`system.md`, `user.txt`). Override: `POLISH_TEMPLATES_PATH`.

Templates use Handlebars (`{{variable}}`, `{{#each}}`, `{{#if}}`).

#### Design decisions

- **Audit re-runs MAIN (not the audit model):** The MAIN step has the full prompt and skill context; re-running it with audit feedback yields better corrections.
- **Polish does not re-run MAIN:** Polish is surface-level (tone, format); a dedicated rewrite step is cheaper and sufficient.
- **Audit output is Markdown (Flex-MD), not forced JSON:** More robust across models; runtime parses structured sections.
- **nx-cache for audit/synthesis:** Reduces cost for repeated or batch runs with identical inputs.
- **No global cost guardrails** (e.g. `maxTotalPostCalls`) in this release; control via `maxCycles` and `maxPasses`.
- **No streaming** for POST steps; final output is returned after all POST steps complete.

#### Testing

POST steps are covered by unit and integration tests in `test/post-steps/`:

- **resolvePostStepConfig.test.ts** — model and timeout resolution (config, env, fallback).
- **parseAuditOutput.test.ts** — parsing audit structured markdown into checks and overall feedback.
- **parsePolishOutput.test.ts** — parsing polish JSON (including code-fenced) into `polishedOutput` and `changeNotes`.
- **runAuditPostStep.test.ts** — audit step with mocked gateway: passing/failing markdown, fallback, and audit-call failure.
- **runPolishPostStep.test.ts** — polish step with mocked gateway: valid JSON, call failure fallback, multiple passes.
- **pipeline-post-steps.test.ts** — pipeline integration: `runTask` with `executionPipeline` (main + audit, main + polish) and mocked executor/gateways; asserts `metadata.postSteps` and final output.

Run post-steps tests: `npm test` (includes `dist-test/test/post-steps/*.js`).

### Types

Use **`RunTaskResponseWithSteps<TParsed>`** when you expect steps; it extends `RunSkillResponse` with `intermediateSteps?: IntermediateStep[]`. When POST steps run, `response.metadata.postSteps` is set (`audit?: AuditPostStepMetadata`, `polish?: PolishPostStepMetadata`). Exported POST step types: `AuditConfig`, `AuditCheck`, `AuditResult`, `AuditCheckResult`, `AuditLLMOutput`, `AuditPostStepMetadata`, `PolishConfig`, `PolishChecklistItem`, `PolishLLMOutput`, `PolishPostStepMetadata`, `RunTaskResponsePostStepsMetadata`. See the [Types](#intermediate-step-intermediate-steps-runtaskresponsewithsteps) section and [documenations/intermediate-steps.md](./documenations/intermediate-steps.md) for full details.

---

## How completion budgets work (8.4+)

Orchestrators send **model** + **sampling** + **`reasoningEffort`** + (for xynthesis) **`outputExpectation`**. They do **not** send completion token counts.

| Phase | Package | Caller sends | Package owns |
|-------|---------|--------------|--------------|
| **MAIN** | `@exellix/ai-skills` ≥6.4 | `modelConfig` → `{ model, temperature, topP, reasoningEffort, … }` | Optimixer + Catalox skill catalog (`maxTokens` on `ModelConfig` is rejected); gateway **`@x12i/ai-gateway` ≥10.1** supplies wire `maxTokens` and authoritative **`costUsd`** / **`costStatus`** on invoke metadata; **one automatic re-invoke** when output hits token limit and Optimixer returns `retryPrediction` |
| **PRE / POST / scoping / utility** | `@exellix/xynthesis` ≥4.5 | `outputExpectation` (required on every hop), optional `reasoningEffort`, `temperature`, `topP` | `resolveEffectiveMaxTokens` inside `executeXynthesisAction` (default **`@x12i/funcx`** invoker); authoritative billing from funcx/gateway engines — xynthesis is pass-through only; **same one-shot max-token retry** via Optimixer `retryPrediction` |

**Xynthesis `outputExpectation`** is sizing/density **intent** for Optimixer — not a literal token count. ai-tasks resolves a per-stage default when the caller omits it (`resolveOutputExpectation` / `resolveLlmOutputExpectationForXynthesis`).

**Rejected on the wire (validation):** `modelConfig.maxTokens`, `llmCall.maxTokens`, `llmCall.maxTokensCap`, deprecated `xynthesisModel`, root `modelConfig.model`.

**Trace:** use `usage.maxTokensRequested` / `invokeSummary` diagnostics after invoke — not pre-set caps on the request.

**Billing (gateway 10.x):** MAIN cost is authoritative from `@x12i/ai-gateway` invoke **`metadata`** only — ai-tasks and ai-skills do not re-price. When `costStatus === 'unpriced'`, tokens are recorded but there is no authoritative USD total (do not price locally). See **[UPGRADING_AI_GATEWAY_10.md](../ai-skills/docs/UPGRADING_AI_GATEWAY_10.md)** §1.

---

## Model profile aliases (`@x12i/ai-profiles`)

`@exellix/ai-tasks` resolves **profile aliases** to concrete provider model ids via [`@x12i/ai-profiles`](https://www.npmjs.com/package/@x12i/ai-profiles) at execution time. This applies to **both** execution paths:

| Path | Slot / field | Resolved before |
|------|----------------|-----------------|
| **Xynthesis** (PRE synthesis, POST audit/polish, AI scoping, utilities) | `preActionModel`, `postActionModel`, step `llmCall.model`, `RunUtilityRequest.exec.model` | `executeXynthesisAction` / `runPostStepLlmCall` |
| **ai-skills MAIN** | `skillModel`, `RunTaskRequest.llmCall.model` | `runSkill` (via `DirectExecutionStrategy`) |

**Accepted alias shapes** (`@x12i/ai-profiles` v2.1+):

- **Profile/choice keys (required on stored `modelConfig` and xynthesis `llmCall.model`):** `cheap/default`, `pro/default`, `cyber/default`, …
- **Concrete provider wire ids on MAIN only:** `openrouter/...`, `anthropic/claude-sonnet-4`, bare vendor SKUs like `gpt-4o-mini` — accepted on **`skillModel`** / MAIN `llmCall.model` only

**Rejected on stored config:**

- Bare profile names (`cheap`, `pro`) — use `cheap/default`, `pro/default`
- `profile@choice` (`cheap@default`) — use `profile/choice` slash form
- Legacy graph tiers (`weak`, `strong`), deprecated `xynthesisModel`, root `modelConfig.model`

**List available keys:** `listAiTasksProfileChoices()` / `listAiTasksProfileChoiceKeys()` (re-exported from `@x12i/ai-profiles` via `@exellix/ai-tasks`). Same registry as xynthesis (`listAIProfiles` / `listAIProfileChoices` on `@exellix/xynthesis`).

**Example (canonical triplet — resolved inside `runTask`):**

```typescript
await runTask({
  skillKey: "tasks/analysis",
  agentId: "agent-1",
  jobTypeId: "analysis-job",
  taskTypeId: "analysis-task",
  executionStrategies: [],
  input: { topic: "Q1 risks" },
  modelConfig: {
    preActionModel: "cheap/default",   // PRE synthesis (xynthesis)
    postActionModel: "cheap/default",  // POST audit/polish (xynthesis)
    skillModel: "openai/gpt-5",         // MAIN skill (ai-skills → ai-gateway)
  },
});
```

Concrete ids pass through unchanged on **`skillModel`** / MAIN `llmCall.model`. **`preActionModel`** / **`postActionModel`** and xynthesis **`llmCall.model`** must stay **profile/choice aliases** — concrete ids are rejected with **`XYNTHESIS_CONCRETE_MODEL_REJECTED`**.

**Client impact:**

- **`skillModel` / MAIN `llmCall.model`:** profile/choice alias or concrete provider wire id.
- **`preActionModel` / `postActionModel`:** profile/choice only — graph-engine must not pre-resolve xynthesis slots to concrete ids.
- **Validation:** `validateRunTaskConfig` / `resolveRunTaskModelReferences` enforce slot shapes before invoke; unknown aliases fail at resolution with **`XYNTHESIS_ALIAS_RESOLUTION_FAILED`**.
- **Helpers:** `resolveModelReference`, `resolveModelReferenceForXynthesis`, `resolveRunTaskModelReferences`, `resolveInvocationPlan`, `isRunTaskModelResolutionError`, `isResolvableModelAlias` (true only for known **`profile/choice`** keys).

### Pre-run invocation plan (`resolveInvocationPlan`)

Side-effect-free preview of which **engine + wire model** each phase will use (same routing path as execute):

```typescript
import { resolveInvocationPlan } from "@exellix/ai-tasks";

const plan = await resolveInvocationPlan({
  profiles: {
    preActionModel: "cheap/default",
    skillModel: "openai/gpt-5",
    postActionModel: "cheap/default",
  },
  policy: {
    preferOpenRouter: true,          // default: read from env when omitted
    openrouterApiKeyPresent: true,   // fallback to vendor-direct when false
  },
});
// plan.phases[] → { phase: "preAction"|"skill"|"postAction", routing, modelId, engineLabel, ... }
```

**OpenRouter routing (`preferOpenRouter`):** when `true` and an OpenRouter API key is present, MAIN (ai-gateway) and PRE/POST (funcx) resolve to different wire shapes — skill phases use `openrouter/...` gateway ids; xynthesis phases use Funcx API slugs. Helpers: `resolvePreferOpenRouterPolicy`, `readPreferOpenRouterFromEnv`, `detectOpenRouterApiKeyPresent`, `resolveProfileInvocationRouting`. See **`@exellix/ai-skills`** [`PREFER_OPENROUTER_ROUTING.md`](../ai-skills/docs/PREFER_OPENROUTER_ROUTING.md).

Resolution uses the ai-profiles registry (`auto`: remote refresh with bundled fallback). See [`MODEL-CONFIGURATION.md`](MODEL-CONFIGURATION.md) and [`BREAKING-CHANGES.md`](BREAKING-CHANGES.md) (8.4+ triplet contract).

---

## LLM call configuration (`LlmCallConfig`)

`LlmCallConfig` is the per-stage override knob set. On MAIN it composes with `RunTaskRequest.modelConfig` slots (`preActionModel`, `skillModel`, `postActionModel`) — when both are set, `llmCall.*` wins.

```typescript
type LlmCallConfig = {
  model?: string;                        // provider/model id or ai-profiles alias (resolved at call time)
  temperature?: number;
  topP?: number;
  reasoningEffort?: OptimixerReasoningEffort; // overrides Catalox default when set
  maxOutputLength?: number;              // post-call character truncation
  timeoutMs?: number;
  outputExpectation?: OutputExpectation; // xynthesis Optimixer intent (not a token count)
};
```

**Per-stage placement:**

| Stage | Where to set `llmCall` |
|-------|------------------------|
| MAIN skill | `RunTaskRequest.llmCall` (overrides `modelConfig.skillModel`) |
| AI scoping (per-instruction) | `RunTaskRequest.aiScoping[].llmCall` |
| AI scoping (request fallback) | `RunTaskRequest.aiScopingOptions.llmCall` |
| PRE synthesis | `SynthesisConfig.llmCall` (on the `synthesized-context` PRE step) |
| POST audit (evaluator) | `AuditConfig.llmCall` or `AuditConfig.audit.llmCall` |
| POST audit (synthesis-merge) | `AuditConfig.audit.synthesis.llmCall` |
| POST polish | `PolishConfig.llmCall` |
| Utility (xynthesis-finalize) | `RunUtilityRequest.exec.{model, temperature, topP, reasoningEffort, outputExpectation, ...}` |

**Builder helpers (`TaskRequestBuilder`):**

- `withLlmCall(llmCall)` — main skill.
- `withAiScopingOptions({ llmCall, concurrency, timeoutMsPerItem })` — fallback for every `aiScoping[]` item.
- `withSynthesisLlmCall(llmCall)` — PRE `synthesized-context` step.
- `withAuditLlmCall(llmCall, role?)` — POST audit step (`role: "audit" | "synthesis"`, omit for top-level).
- `withPolishLlmCall(llmCall)` — POST polish step.
- `withXynthesized(memory)` / `withSmartInput(config)` / `withSmartInputPaths(paths)` / `withSmartInputRenderOptions(opts)` — graph **`xynthesized`** / **`smartInput`** contract (see [Xynthesized memory and smart input](#xynthesized-memory-and-smart-input)).
- `withXynthesizedJob(key, value)` / `withXynthesizedTask(key, value)` / `withXynthesizedExecution(key, value)` — merge one key into **`xynthesized.job`** / **`xynthesized.task`** / **`xynthesized.execution`**.

**Environment variables (POST steps + AI scoping):** every stage reads from per-stage env vars first, then shared `POST_STEP_*` env vars, then the supplied fallback. Order is: `config.<field>` → step env → shared env → fallback.

| Env var (per stage) | Effect |
|---------------------|--------|
| `AUDIT_MODEL`, `POLISH_MODEL`, `SYNTHESIS_MODEL`, `AI_SCOPING_MODEL` | Model override for that stage |
| `AUDIT_TIMEOUT_MS`, `POLISH_TIMEOUT_MS`, `SYNTHESIS_TIMEOUT_MS`, `AI_SCOPING_TIMEOUT_MS` | Per-call timeout override |
| `AUDIT_TEMPERATURE`, `POLISH_TEMPERATURE`, `SYNTHESIS_TEMPERATURE` | Temperature override |
| `AUDIT_TOP_P`, `POLISH_TOP_P`, `SYNTHESIS_TOP_P` | Top-P override |
| `AUDIT_MAX_OUTPUT_LENGTH`, `POLISH_MAX_OUTPUT_LENGTH`, `SYNTHESIS_MAX_OUTPUT_LENGTH` | Post-call char truncation override |
| `AI_SCOPING_CONCURRENCY` | Max concurrent scoping calls (default 5) |
| `AI_SCOPING_TIMEOUT_MS_PER_ITEM` | Per-item timeout in ms (default 30_000) |

| Shared env var | Effect |
|----------------|--------|
| `POST_STEP_MODEL` | Default model for any POST step that didn't get a specific value |
| `POST_STEP_TIMEOUT_MS` | Default timeout |
| `POST_STEP_MAX_TOKENS_CAP` | **Deprecated** — rejected on `llmCall`; Optimixer owns caps |
| `POST_STEP_TEMPERATURE` | Default temperature |
| `POST_STEP_TOP_P` | Default Top-P |
| `POST_STEP_MAX_OUTPUT_LENGTH` | Default char truncation |

`outputExpectation` is intentionally NOT read from env vars (too structured).

---

## Trace mode observability for LLM calls

When `RunTaskRequest.executionMode === "trace"` (or `RunUtilityRequest.exec.executionMode === "trace"`), `@exellix/ai-tasks` populates a structured `LlmCallObservation` for **every** LLM call attempted (success or failure) and surfaces them on response metadata:

| `result.metadata` field | Stage | Notes |
|-------------------------|-------|-------|
| `synthesizedContextLlmCalls?: LlmCallObservation[]` | PRE synthesis (markdown / question-driven / structured) | one entry per question or branch |
| `aiScopingLlmCalls?: LlmCallObservation[]` | AI scoping | one entry per `aiScoping[]` instruction |
| `aiScopingFailures?: AiScopingFailureRecord[]` | AI scoping | **always** populated when any item failed (regardless of trace mode) — previously these were silently dropped |
| `postSteps.audit.llmCalls?: LlmCallObservation[]` | POST audit (evaluator + synthesis-merge) | per-cycle entries |
| `postSteps.polish.llmCalls?: LlmCallObservation[]` | POST polish | per-pass entries |
| `intermediateSteps[].observation?: LlmCallObservation` | any failed POST step | attached to the failure step so consumers can read the request/response context inline |

`LlmCallObservation` is a discriminated union over `source: "xynthesis" | "ai-skills"`:

```typescript
type LlmCallObservation =
  | {
      source: "xynthesis"; stage: LlmCallStage; stepId?: string;
      request: LlmCallRequestSnapshot;
      summary?: InvokeAttemptSummary;  // from executeXynthesisAction on success / typed errors on failure
      debugTrace?: XynthesisDebugTrace; // present when executionMode: "trace" was forwarded
      durationMs: number; ok: boolean; error?: { name: string; message: string };
    }
  | {
      source: "ai-skills"; stage: "main-skill"; stepId?: string;
      request: LlmCallRequestSnapshot;
      diagnostics?: RunSkillDiagnostics; // from runSkill in trace mode
      durationMs: number; ok: boolean; error?: { name: string; message: string };
    };
```

**`InvokeAttemptSummary`** (xynthesis ≥ 4.5) carries `{ jobId, taskId, modelRequested, modelResolved, maxTokensFromCaller, maxTokensEffective, modelUsedFromProvider, usage, routing, costUsd, costStatus, traceKind, executionMetadata }`. USD cost is **authoritative from `@x12i/funcx`** — xynthesis does not calculate pricing locally.

**`RunSkillDiagnostics`** (ai-skills ≥ 6.4) carries `{ trace: { step, timing, routing, usage, costUsd, costStatus, modelUsed, attempts[], rawProviderPayload?, invokeRequest? }, ... }`. USD cost is **authoritative from `@x12i/ai-gateway` ≥10.1** — ai-skills normalizes gateway `metadata` only (`mapGatewayInvokeToTrace`, `traceToDiagnostics` re-exported from `@exellix/ai-tasks`). Gateway `costStatus: "priced"` is represented as `costUsd`; `"unpriced"` surfaces as `costStatus: "unpriced"`. For non-trace MAIN failures, read **`(error as Error & { metadata?: GatewayInvokeRejectionMetadata }).metadata`** (re-exported from `@exellix/ai-tasks`) — do not scrape error strings.

### Typed errors (instanceof-friendly)

Failures from xynthesis call sites are returned as upstream typed errors that already carry rich context — `@exellix/ai-tasks` passes them through unchanged so callers can `instanceof` them:

| Error class | Carries | Where |
|-------------|---------|-------|
| `XynthesisInvokeError` | `invokeSummary`, `cause` | xynthesis invoke failure (PRE / POST / scoping / utility) |
| `XynthesisResponseParseError` | `stage`, `invokeSummary`, `responseLength`, `responsePreview`, `actionType?`, `cause` | xynthesis parse failure |
| `SkillExecutionTraceError` | `RunSkillDiagnostics` (incl. `trace.invokeRequest` echo) | MAIN skill failure in trace mode |
| `LlmCallContextError` | `observation: LlmCallObservation`, `cause` | thin wrapper for the few sites where the upstream still throws untyped (raw `setTimeout` timeouts, MAIN skill outside trace mode, AIGateway repair fallback) |

All of these are re-exported from `@exellix/ai-tasks`:

```typescript
import {
  XynthesisInvokeError,
  XynthesisResponseParseError,
  SkillExecutionTraceError,
  LlmCallContextError,
  type LlmCallObservation,
  type LlmCallConfig,
  type InvokeAttemptSummary,
  type RunSkillDiagnostics,
  resolveOutputExpectation,
  ACTION_OUTPUT_DEFAULTS,
  mapGatewayInvokeToTrace,
  traceToDiagnostics,
  buildInvokeAttemptSummary,
  getModelCapabilities,
} from "@exellix/ai-tasks";
```

### Worked example

```typescript
import { runTask, type LlmCallConfig } from "@exellix/ai-tasks";

const llmCall: LlmCallConfig = {
  model: "gpt-5-mini",
  outputExpectation: { size: { kind: "absolute", maxWords: 600 }, density: "default" },
  temperature: 0.4,
  reasoningEffort: "medium",
};

const result = await runTask({
  skillKey: "tasks/security-risk-summary",
  agentId: "agent-1",
  jobTypeId: "security-job",
  taskTypeId: "security-task",
  executionStrategies: [],
  modelConfig: {
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "openai/gpt-5",
  },
  input: { assetId: "a-123" },
  executionMode: "trace",
  llmCall,
  aiScopingOptions: { llmCall: { model: "cheap/default", outputExpectation: { size: { kind: "absolute", maxWords: 200 }, density: "concise" } } },
  // executionPipeline: [{ phase: "pre", type: "synthesized-context", config: { llmCall } }, { phase: "main", type: "direct" }],
});

const m = (result as any).metadata ?? {};
console.log("PRE synth calls:",   m.synthesizedContextLlmCalls?.length ?? 0);
console.log("AI scoping calls:",  m.aiScopingLlmCalls?.length ?? 0);
console.log("AI scoping fails:",  m.aiScopingFailures?.length ?? 0);
console.log("audit calls:",       m.postSteps?.audit?.llmCalls?.length ?? 0);
console.log("polish calls:",      m.postSteps?.polish?.llmCalls?.length ?? 0);
```

---

## Graph Execution Support

### Overview

`@exellix/ai-tasks` supports optional graph execution context fields that enable smarter activity tracking and identity for nodes executing within graph workflows. When executing tasks in a graph (e.g., via `worex-graphs`), you can provide additional context fields to improve activity tracking and debugging.

The client automatically passes these fields through to `@exellix/ai-skills`, which then passes them to `@x12i/ai-gateway`. The gateway maps them to the activity identity structure according to `@x12i/activix-tracking` v3.5.0+ mapping rules.

### Optional Fields

#### `graphId` (optional)

**Type:** `string`  
**Description:** Identifier for the graph execution context

Used when executing tasks within a graph to track which graph contains the task. This enables:
- Activity grouping and filtering by graph
- Better activity identity for graph-based workflows
- Enhanced debugging and traceability

**Example:**
```typescript
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'graph-456',  // Graph identifier
  nodeId: 'node-789',     // Node identifier
});
```

**Use Cases:**
- Graph workflow execution tracking
- Multi-graph job orchestration
- Graph-level activity aggregation

#### `nodeId` (optional)

**Type:** `string`  
**Description:** Identifier for the specific node being executed

Used when executing tasks within a graph to track which specific node is being executed. When provided along with `graphId`, enables:
- Precise activity identity for graph nodes
- Node-level activity tracking and debugging
- Better correlation between graph structure and executed activities

**Example:**
```typescript
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',  // Graph identifier
  nodeId: 'extract-emails-node',        // Node identifier
});
```

**Use Cases:**
- Individual node execution tracking
- Node-level activity analysis
- Graph node debugging

#### `prevNodeId` (optional)

**Type:** `string`  
**Description:** Identifier for the previous node in the graph execution flow

Used when executing tasks within a graph to track which node was executed before the current one. This enables:
- Graph flow tracking and debugging
- Understanding execution order in graph workflows
- Better activity correlation between consecutive nodes

**Example:**
```typescript
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  nodeId: 'extract-emails-node',
  prevNodeId: 'validate-input-node',    // Previous node identifier
});
```

**Use Cases:**
- Graph execution flow tracking
- Understanding node execution sequence
- Debugging graph workflow issues

#### `coreSkillId` (optional)

**Type:** `string`  
**Description:** Alternative node identifier for graph execution

Used as an alternative to `nodeId` when executing tasks within a graph. This field is automatically mapped to `identity.nodeId` in activity tracking, with priority over `nodeId` but below `skillId` (from the underlying skill request).

**Example:**
```typescript
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  coreSkillId: 'q0',  // Alternative node identifier
});
```

**Use Cases:**
- Alternative node identification (common in worex-graphs integration)
- Node-level tracking when using coreSkillId naming convention

### Field Mapping

The client automatically passes graph/node fields through `@exellix/ai-skills` to the underlying `@x12i/ai-gateway`, which maps them to the activity identity structure. The gateway uses the following priority rules:

#### Graph ID Mapping

1. `masterSkillId` (primary) → `identity.graphId`
2. `graphId` (fallback) → `identity.graphId`

#### Node ID Mapping

1. `skillId` (primary, from underlying skill request) → `identity.nodeId`
2. `coreSkillId` (alternative) → `identity.nodeId`
3. `nodeId` (fallback) → `identity.nodeId`

#### Previous Node ID Mapping

1. `prevNodeId` → `identity.prevNodeId` (automatically mapped for graph flow tracking)

#### Preserved Fields

- `masterSkillId` → `identity.masterSkillId` (preserved for backward compatibility)
- `masterSkillActivityId` → `identity.masterSkillActivityId` (preserved for skill hierarchies)

This enables:
- **Smarter Identity**: Activity records include graph and node context automatically
- **Better Grouping**: Filter and aggregate activities by graph or node
- **Enhanced Debugging**: Trace activities back to specific graph nodes
- **Improved Analytics**: Analyze graph execution patterns and node performance
- **Flexible Integration**: Support for multiple field naming conventions

### Complete Field Mapping Guide

| @exellix/ai-tasks Field | @exellix/ai-skills Field | @x12i/ai-gateway Field | Identity Field | Mapping Priority |
|-------------------------|--------------------------|----------------------------|----------------|------------------|
| `masterSkillId` | `masterSkillId` | `masterSkillId` (AIRequest) | `identity.graphId` | Priority 1 (primary) |
| `graphId` | `graphId` | `graphId` | `identity.graphId` | Priority 2 (fallback) |
| `skillId` (via skills) | `skillId` (AIRequest) | `skillId` (AIRequest) | `identity.nodeId` | Priority 1 (primary) |
| `coreSkillId` | `coreSkillId` | `coreSkillId` | `identity.nodeId` | Priority 2 (alternative) |
| `nodeId` | `nodeId` | `nodeId` | `identity.nodeId` | Priority 3 (fallback) |
| `skillKey` | `skillKey` | `instructions` | - | Direct mapping |
| `jobId` | `jobId` | `jobId` | `identity.jobId` | Direct mapping |
| `agentId` | `agentId` | `agentId` | `identity.agentId` | Direct mapping |
| `masterSkillActivityId` | `masterSkillActivityId` | `masterSkillActivityId` (AIRequest) | `identity.masterSkillActivityId` | Preserved |

**Note:** `masterSkillId` is a legacy field that is still supported for backward compatibility. The new `graphId` and `nodeId` fields provide a cleaner API for graph-based workflows.

### Usage Examples

#### Basic Graph Node Execution

```typescript
import { WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";

const tasks = new WorexClientTasks(skills, executor);

// Execute a task within a graph
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  nodeId: 'extract-emails-node',
});
```

#### Graph Execution with Legacy Fields

```typescript
// Using legacy masterSkillId (still supported)
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  masterSkillId: 'workflow-data-processing',  // Maps to identity.graphId
  coreSkillId: 'extract-emails-node',          // Maps to identity.nodeId
});
```

#### Graph Execution with coreSkillId

```typescript
// Using coreSkillId (common in worex-graphs)
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  coreSkillId: 'q0',  // Alternative node identifier
});
```

#### Graph Execution Without Node ID

```typescript
// Graph-level execution (no specific node)
const result = await tasks.runTask({
  skillKey: 'tasks/process-data',
  executionType: ExecutionType.DIRECT,
  input: { data: '...' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  // nodeId not provided - still works
});
```

#### Non-Graph Execution (Backward Compatible)

```typescript
// Standard execution (no graph context)
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  // graphId and nodeId not provided - works as before
});
```

#### Using TaskRequestBuilder with Graph Context

```typescript
import { TaskRequestBuilder } from "@exellix/ai-tasks";

const builder = new TaskRequestBuilder();
const request = builder
  .withSkillKey('tasks/security-risk-summary')
  .withInput({ assetId: 'a-123' })
  .withGraphId('workflow-data-processing')
  .withNodeId('extract-emails-node')
  .withJobId('job-123')
  .withAiSkillsCorrelation('agent-1', 'job-type-id', 'task-type-id')
  .build();

const result = await tasks.runTask(request);
```

#### Using TaskRequestBuilder with Memory Management

```typescript
import { TaskRequestBuilder } from "@exellix/ai-tasks";
import type { JobHistory, TaskHistory, ExecutionHistory } from "@exellix/ai-tasks";

const jobMemory: JobHistory = { /* previous task results */ };
const taskMemory: TaskHistory = { /* previous skill executions */ };
const executionMemory: ExecutionHistory = { /* execution context */ };

const builder = new TaskRequestBuilder();
const request = builder
  .withSkillKey('tasks/my-task')
  .withInput({ data: 'process' })
  .withJobMemory(jobMemory)
  .withTaskMemory(taskMemory)
  .withExecutionMemory(executionMemory)
  .withJobId('job-123')
  .withAiSkillsCorrelation('agent-1', 'job-type-id', 'task-type-id')
  .build();

const result = await tasks.runTask(request);
```

#### Using Convenience Methods with Graph Context

```typescript
import { runTask, ExecutionType } from "@exellix/ai-tasks";

// Convenience method automatically initializes SDK (ERC mode)
const result = await runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'workflow-data-processing',
  nodeId: 'extract-emails-node',
});
```

### Integration with Graph Runtimes

When integrating with graph execution runtimes (e.g., `worex-graphs`), you can pass graph/node fields directly - the client automatically passes them through to the underlying skills client and gateway.

#### Automatic Mapping (Recommended)

The client automatically passes fields from graph runtimes to the gateway. You can pass fields directly without manual mapping. **Production calls** must also include **`jobTypeId`**, **`taskTypeId`**, and **`executionStrategies`** (often `[]`); the snippets below omit them for brevity.

```typescript
// worex-graphs sends fields directly - client handles passing through automatically
const requestFromGraph = {
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  jobId: 'job-123',
  agentId: 'agent-1',
  // jobTypeId, taskTypeId, executionStrategies: [] — required in real requests
  masterSkillId: 'graph-question-breakdown',  // Automatically mapped to identity.graphId
  coreSkillId: 'q0',                          // Automatically mapped to identity.nodeId
  masterSkillActivityId: 'job-123:q0',       // Preserved in identity
  input: { assetId: 'a-123' },
  // ... other fields
};

// Pass fields directly - client passes through automatically
const result = await tasks.runTask({
  skillKey: requestFromGraph.skillKey,
  executionType: ExecutionType.DIRECT,
  input: requestFromGraph.input,
  jobId: requestFromGraph.jobId,
  agentId: requestFromGraph.agentId,
  masterSkillId: requestFromGraph.masterSkillId,  // ✅ Automatically passed to gateway
  coreSkillId: requestFromGraph.coreSkillId,      // ✅ Automatically passed to gateway
  masterSkillActivityId: requestFromGraph.masterSkillActivityId,  // ✅ Automatically passed to gateway
  // ... other fields
});
```

#### Using New Graph Fields (Alternative)

Alternatively, you can map to the new direct fields (`graphId`/`nodeId`):

```typescript
// Map to new graph fields
const result = await tasks.runTask({
  skillKey: requestFromGraph.skillKey,
  executionType: ExecutionType.DIRECT,
  input: requestFromGraph.input,
  jobId: requestFromGraph.jobId,
  agentId: requestFromGraph.agentId,
  graphId: requestFromGraph.masterSkillId,  // Direct graph identifier
  nodeId: requestFromGraph.coreSkillId,     // Direct node identifier
  // ... other fields
});
```

#### Xynthesized memory and `smartInput` from graph-engine

Graph runtimes can pass **`xynthesized`** (**`job`**, **`task`**, and **`execution`** buckets) and **`smartInput`** (**Rendrix paths** or legacy **`paths: string[]`**) on the same `runTask` request. ai-tasks **normalizes** legacy string paths, **forwards** **`smartInput`** / **`smartInputRenderOptions`** to local **`ctx`**, PRE synthesis, MAIN **`runSkill`**, and merged **template `variables`**; it **does not** apply graph output mapping or merge patches into a global store—that is the engine’s job after reading **`response.xynthesizedPatch`** (**`job`**, **`task`**, and/or **`execution`** slices) and any **`response.smartInputRenderResult`** from downstream. See [Xynthesized memory and smart input](#xynthesized-memory-and-smart-input).

### Activity Record Structure

When graph context is provided, activity records include the mapped fields in the identity structure:

```typescript
{
  _id: ObjectId('...'),
  jobId: 'job-123',
  agentId: 'agent-1',
  taskId: 'task-456',
  taskTypeId: 'abc123...',
  identity: {
    jobId: 'job-123',
    agentId: 'agent-1',
    taskId: 'task-456',
    taskTypeId: 'abc123...',
    graphId: 'workflow-data-processing',        // ← Automatically mapped from masterSkillId or graphId
    nodeId: 'extract-emails-node',             // ← Automatically mapped from skillId, coreSkillId, or nodeId
    masterSkillId: 'workflow-data-processing',  // ← Preserved if provided (backward compatibility)
    masterSkillActivityId: 'job-123:node-456'  // ← Preserved if provided (skill hierarchies)
  },
  request: { /* ... */ },
  config: { /* ... */ },
  // ... other fields
}
```

### Benefits

1. **Enhanced Activity Tracking**: Activities include graph and node context for better traceability
2. **Smarter Identity**: Activity identity is enriched with graph/node information
3. **Better Debugging**: Easier to correlate activities with graph structure
4. **Improved Analytics**: Analyze execution patterns at graph and node levels
5. **Flexible Integration**: Optional fields don't break existing integrations
6. **Backward Compatible**: Legacy `masterSkillId` field still works

### Migration Guide

#### For Existing Integrations

These fields are **optional**, so existing integrations continue to work without changes:

```typescript
// ✅ Existing code continues to work
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  // graphId and nodeId not required
});
```

#### Adding Graph Support

To add graph execution support, simply include the optional fields:

```typescript
// ✅ Add graph context for better tracking
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  jobId: 'job-123',
  agentId: 'agent-1',
  graphId: 'my-graph-id',    // Add graph context
  nodeId: 'my-node-id',      // Add node context (optional)
});
```

#### Migrating from Legacy Fields

If you're using `masterSkillId`, you can optionally migrate to the new `graphId` field for clarity:

```typescript
// Before (still works)
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  masterSkillId: 'graph-id',
  coreSkillId: 'node-id',
});

// After (cleaner API)
const result = await tasks.runTask({
  skillKey: 'tasks/security-risk-summary',
  executionType: ExecutionType.DIRECT,
  input: { assetId: 'a-123' },
  graphId: 'graph-id',  // More explicit
  nodeId: 'node-id',    // More explicit
});
```

### TypeScript Types

All graph execution fields are properly typed in the TypeScript definitions:

```typescript
interface RunTaskRequest {
  skillKey: string;
  agentId: string;       // Required by @exellix/ai-skills
  jobTypeId: string;
  taskTypeId: string;
  input: Record<string, any> | string;
  executionType: ExecutionType;
  // ... other fields ...
  graphId?: string;        // Graph execution context
  nodeId?: string;         // Node identifier
  coreSkillId?: string;    // Alternative node identifier
  masterSkillId?: string;  // Legacy graph identifier (still supported)
  // ... other fields ...
}
```

### Related Documentation

- [@exellix/ai-skills — UPGRADING_AI_GATEWAY_10.md](../ai-skills/docs/UPGRADING_AI_GATEWAY_10.md) — gateway 10.x migration (billing, required fields, Activix persistence)
- [@exellix/ai-skills Graph Execution Support](https://github.com/exellix/ai-skills#graph-execution-support) - Skills-level documentation
- [@x12i/ai-gateway Graph Execution Support](https://github.com/athenices/ai-gateway#graph-execution-support) - Gateway-level documentation
- [@x12i/activix-tracking](https://github.com/athenices/ai-activities-tracking) - Activity tracking specification

### Notes

- **`agentId` / `jobTypeId` / `taskTypeId` are required** on every `runTask` (see [`RUNTASK_REQUEST.md`](RUNTASK_REQUEST.md)).
- Graph/node fields (`graphId`, `nodeId`, …) remain optional for non-graph runs — provide them when executing tasks in graphs.
- Fields are automatically passed through to the skills client and gateway, then mapped to activity identity.
- Multiple field naming conventions supported:
  - New fields: `graphId`/`nodeId`/`coreSkillId` (recommended for new code)
  - Legacy fields: `masterSkillId` (still supported for backward compatibility)
- Mapping priority ensures consistent identity structure regardless of field naming.
- Best practice: Include graph/node context fields for graph task execution for maximum traceability.

---

## API Reference

### `WorexClientTasks`

Main class for task execution.

#### Constructor

```typescript
constructor(
  skillsClient: WorexClientSkills,
  executor: {
    execute<TParsed = any>(
      input: any,
      onNotFound: (key: string) => Promise<void>
    ): Promise<RunTaskResponse<TParsed>>;
  }
)
```

#### Methods

##### `runTask<TParsed>(input: RunTaskRequest): Promise<RunTaskResponse<TParsed>>`

Executes a task with task-level enrichment and context generation.

```typescript
const result = await tasks.runTask({
  skillKey: "tasks/security-risk-summary",
  agentId: "my-agent",
  jobTypeId: "security-risk-job",
  taskTypeId: "security-risk-task",
  // executionType is optional, defaults to ExecutionType.DIRECT
  input: { assetId: "a-123" },
  variables: { orgName: "Acme" },
  jobMemory: previousJobMemory,
  taskMemory: previousTaskMemory,
  executionMemory: previousExecutionMemory,
  jobId: "job-123"
});
```

#### Local task registry

- **`registerLocalTask(skillKey: string, handler: LocalTaskHandler): void`** — Register a local task handler. When `runTask` is called with this `skillKey`, the handler runs instead of the normal enrichment/executor path.
- **`getLocalTask(skillKey: string): LocalTaskHandler | undefined`** — Return the registered handler for `skillKey`, or `undefined`.
- **`registerBuiltInLocalTasks(): void`** — Register built-in handlers (`skills/node.callExport`, `skills/node.callExportBatch`, `skills/skill.local:validateInput`, `skills/skill.local:normalizeNarrixResult`). The Narrix skill `skills/skill.local:narrixRun` (record/text/docs/chat) is registered via a side-effect import and is available when the package is loaded.
- **`LocalTaskContext`** — Type: `skillKey`, `jobMemory`, `taskMemory`, `executionMemory`, `variables`, `xynthesized`, `smartInput`, `jobId`, `taskId`, `agentId`, `graphId`, `nodeId`, `prevNodeId`, `coreSkillId`, `masterSkillId`, `masterSkillActivityId`.
- **`LocalTaskHandler`** — Type: `(args: { input: any; ctx: LocalTaskContext }) => Promise<any>`.

### `TaskRequestBuilder`

Builder class for constructing task requests with a fluent API.

#### Methods

- `withSkillKey(skillKey: string): this` - Set the skill key
- `withInput(input: Record<string, any> | string): this` - Set input data
- `withExecutionStrategies(invocations: ExecutionStrategyInvocation[]): this` — MAIN strategy invocations (**required** on the built request; defaults to **`[]`** in **`build()`** when unset)
- `withExecutionPipeline(steps: ExecutionStep[]): this` - Set execution pipeline (pre/main/post steps)
- `withSynthesizedContextPreStep(modelOrConfig?: string | SynthesisConfig): this` - Add synthesized-context PRE step and set `includeContextInPrompt: true`. Pass a **`SynthesisConfig`** object to set **`contextSourcePolicy`**, **`webEvidence`**, **`memoryPaths`**, etc.
- `withAuditPostStep(config: AuditConfig): this` - Add audit POST step (quality-gate loop)
- `withPolishPostStep(config: PolishConfig): this` - Add polish POST step (refinement checklist)
- `withVariables(variables: Record<string, any>): this` - Set contextual variables
- `withContext(context: Record<string, any> | string): this` - Set context
- `withKnowledge(knowledge: Record<string, any>): this` - Set knowledge data
- `withJobMemory(jobMemory: JobHistory): this` - Set job memory
- `withTaskMemory(taskMemory: TaskHistory): this` - Set task memory
- `withExecutionMemory(executionMemory: ExecutionHistory): this` - Set execution memory
- `withJobId(jobId: string): this` - Set job ID
- `withAgentId(agentId: string): this` - Set agent ID
- `withJobTypeId(jobTypeId: string): this` - Set job type id (**required** before `build()`)
- `withTaskTypeId(taskTypeId: string): this` - Set task type id (**required** before `build()`)
- `withAiSkillsCorrelation(agentId: string, jobTypeId: string, taskTypeId: string): this` - Set all three correlation ids (recommended)
- `withGraphId(graphId: string): this` - Set graph identifier
- `withNodeId(nodeId: string): this` - Set node identifier
- `withPrevNodeId(prevNodeId: string): this` - Set previous node identifier
- `withModelConfig(modelConfig: RunTaskModelConfig): this` - Set the required 3-slot model triplet + tuning
- `withTemplateRenderOptions(templateRenderOptions: TemplateRenderOptions): this` - Per-task gateway / parser template overrides (v4)
- `withTemplateTokens(templateTokens: GatewayTemplateTokens): this` - Per-task `templateTokens` overlay
- `withTimeout(timeoutMs: number): this` - Set timeout
- `withXynthesized(memory: XynthesizedMemory): this` — optional **`xynthesized`** bucket (**`job`** / **`task`** / **`execution`** records)
- `withSmartInput(config: RunTaskSmartInput): this` — passthrough **`smartInput`** (Rendrix **`paths`** or legacy **`string[]`**)
- `withSmartInputPaths(paths: string[]): this` — builds **`smartInput: { paths: paths.map(p => ({ title: p, path: p })) }`**
- `withSmartInputRenderOptions(opts: SmartInputRenderOptions): this` — sets **`smartInputRenderOptions`** on the request (gateway/Rendrix merge)
- `withXynthesizedJob(key: string, value: unknown): this` / `withXynthesizedTask(key: string, value: unknown): this` / `withXynthesizedExecution(key: string, value: unknown): this` — merge a single key into **`xynthesized.job`** / **`xynthesized.task`** / **`xynthesized.execution`**
- `build(): RunTaskRequest` - Build the final request (**`executionStrategies`** defaults to **`[]`** when unset)

#### Example

```typescript
import { TaskRequestBuilder } from "@exellix/ai-tasks";

const request = new TaskRequestBuilder()
  .withSkillKey("tasks/my-task")
  .withInput({ data: "process" })
  .withAiSkillsCorrelation("my-agent", "my-job-type", "my-task-type")
  .withExecutionStrategies([]) // [] = plain MAIN; omit only if you rely on build() defaulting strategies to []
  .withModelConfig({
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "gpt-5",
    temperature: 0.7,
  })
  .withJobMemory(jobMemory)
  .withTaskMemory(taskMemory)
  .withExecutionMemory(executionMemory)
  .withJobId("job-123")
  .build();

const result = await tasks.runTask(request);
```

---

## Types

### `RunTaskRequest`

```typescript
interface RunTaskRequest {
  skillKey: string;                    // "tasks/security-risk-summary"
  agentId: string;                     // Required — @exellix/ai-skills correlation
  jobTypeId: string;                   // Required — catalog / telemetry job classification
  taskTypeId: string;                  // Required — catalog / telemetry task classification
  /** Required: MAIN strategy invocations; use [] for plain gateway MAIN (see BREAKING-CHANGES / RUNTASK_REQUEST). */
  executionStrategies: ExecutionStrategyInvocation[];
  /** Optional guarded metadata rows from catalogId `execution-strategy`; code validation remains authoritative. */
  executionStrategyCatalogItems?: TaskStrategyItemData[];
  input: Record<string, any> | string; // Task payload (canonical user/host data)
  executionType?: ExecutionType | string;  // Optional; DIRECT default when using plain MAIN; narrix-then-direct when configured
  narrixInput?: NarrixRunInput | { $path: string };  // Required when narrixMode infers handler without preprocessor narrix
  narrixScope?: NarrixScope;            // When set with narrix handler path, filters which signals/stories go to task memory
  /** Task-level NARRIX pre-processor: datasetId, attachToField (CNI attachment only; see [NARRIX task-level pre-processor](#narrix-task-level-pre-processor)). */
  narrix?: NarrixPreProcessorConfig;
  includeContextInPrompt?: boolean;    // Opt-in: when true, task-scoped context is added to the prompt; default is false (no context)

  /** Optional: pre / main / post steps. When set (non-empty), runs PRE (e.g. synthesized-context), one MAIN, then POST. See [Execution pipeline](#execution-pipeline-and-synthesized-context-pre-step). */
  executionPipeline?: ExecutionStep[];

  /** executionMode: "trace" — ordered debugTrace + richer LLM observability (see [Trace mode](#trace-mode-authoritative-ordered-debug-trace)). */
  executionMode?: "default" | "trace";

  /** Merged into template variables before explicit variables (MAIN path). See mergeSkillTemplateVariables. */
  jobContext?: Record<string, unknown>;

  /** Job-, task-, and execution-scoped synthesized buckets for graph execution (distinct from raw memories). */
  xynthesized?: XynthesizedMemory;

  /** Rendrix smart-input paths or legacy `paths: string[]` (normalized before MAIN); root allows only `paths`. */
  smartInput?: RunTaskSmartInput;
  /** Optional Rendrix options for the `{{smartInput}}` macro (merged on gateway like `RunSkillRequest`). */
  smartInputRenderOptions?: SmartInputRenderOptions;

  // Optional fields
  variables?: Record<string, any>;      // Contextual variables
  context?: Record<string, any> | string;
  knowledge?: Record<string, any>;
  jobMemory?: JobHistory;               // History of previous task results
  taskMemory?: TaskHistory;              // History of skills executed
  executionMemory?: ExecutionHistory;    // History of execution context (execution-level memory)
  modelConfig: RunTaskModelConfig;      // Required 3-slot triplet + optional tuning
  jobId?: string;

  // Graph execution context (optional)
  graphId?: string;                    // Graph identifier for activity tracking
  nodeId?: string;                      // Node identifier for activity tracking
  coreSkillId?: string;                 // Alternative node identifier (maps to identity.nodeId)

  // Legacy fields (still supported for backward compatibility)
  masterSkillId?: string;               // Legacy graph identifier (maps to identity.graphId)
  masterSkillActivityId?: string;       // Preserved for skill hierarchies

  timeoutMs?: number;

  /** Per-invoke template parser options (merged on gateway defaults). See [Gateway template rendering (v4)](#gateway-template-rendering-v4). */
  templateRenderOptions?: TemplateRenderOptions;
  /** Highest-priority template token overlay for this invoke. */
  templateTokens?: GatewayTemplateTokens;
}
```

`NarrixPreProcessorConfig` is exported from this package. It configures the CNI attachment only and carries **no** web-scope fields — web scoping is a standalone `webScope` PRE unit.

The closed JSON schema for hosts and codegen is **[documenations/schemas/v1/run-task-request.json](./documenations/schemas/v1/run-task-request.json)** (includes **`xynthesized`** and **`smartInput`**). Narrative contract: **[RUNTASK_REQUEST.md](./RUNTASK_REQUEST.md)**. For **smart input**, the **TypeScript** types exported from this package (**`RunTaskSmartInput`**, **`SmartInputConfig`**) are authoritative; the JSON schema / RUNTASK narrative may still emphasize string **`paths`** only until updated to Rendrix **`{ title, path }`** entries.

`TemplateRenderOptions` and `GatewayTemplateTokens` are re-exported from this package (see [Gateway template rendering (v4)](#gateway-template-rendering-v4)).

### `RunTaskResponse<TParsed>`

ai-tasks extends the skills response with optional **`identity`**, **`xynthesizedPatch`** (**`job`**, **`task`**, and/or **`execution`** writes from PRE synthesis when configured), **`intermediateSteps`**, **`debugTrace`** (when **`executionMode: "trace"`**), and observability-related **`metadata`** extensions. Underlying shape:

```typescript
type RunTaskResponse<TParsed = any> = RunSkillResponse<TParsed> & {
  /** When downstream returns smart-input diagnostics markdown + resolved paths. */
  smartInputRenderResult?: SmartInputRenderResult;
  identity?: Record<string, unknown>;
  xynthesizedPatch?: XynthesizedMemory;
  intermediateSteps?: IntermediateStep[];
  debugTrace?: { tasks: DebugTraceTask[] };
  aiTasksObservability?: AiTasksObservabilityDigest; // usually attached by orchestrators, not by default
};

interface RunSkillResponse<TParsed = any> {
  skillKey: string;
  rawText: string;
  flexMd: {
    frame: string;
    payloads: Record<string, string>;
  };
  parsed: TParsed;
  metadata: {
    instructionVersion?: string;
    activityId?: string;
    durationMs?: number;    // Present for local tasks (execution tracing)
    localSkillKey?: string;   // Present for local tasks (e.g. "skills/node.callExportBatch")
    narrix?: {              // Present when narrix-then-direct ran and Narrix succeeded (or failed; see parsed.phase)
      entity: { entityKind: string; entityKey: string };
      signals: unknown[];
      stories: unknown[];
      meta: Record<string, unknown>;
      durationMs?: number;
    };
  };
}
```

Task responses **may** include an optional top-level **`intermediateSteps`** (array of `IntermediateStep`) for combined/multi-step tasks; see [Intermediate steps (multi-step tasks)](#intermediate-steps-multi-step-tasks).

### `IntermediateStep`, `IntermediateSteps`, `RunTaskResponseWithSteps`

For multi-step tasks, use the extended response type and step shape:

```typescript
import type { IntermediateStep, IntermediateSteps, RunTaskResponseWithSteps } from "@exellix/ai-tasks";

interface IntermediateStep {
  step: number;        // 1-based order
  id: string;          // e.g. "to-cni", "engine-enrich"
  ok: boolean;
  summary?: string;
  error?: string;
  inputSummary?: Record<string, unknown> | string;
  outputExcerpt?: Record<string, unknown>;
}

type IntermediateSteps = IntermediateStep[];

interface RunTaskResponseWithSteps<TParsed = any> extends RunSkillResponse<TParsed> {
  intermediateSteps?: IntermediateSteps;
}
```

See [documenations/intermediate-steps.md](./documenations/intermediate-steps.md) for full documentation and examples.

### `ExecutionPhase`, `ExecutionStep`, `SynthesisConfig`, `ContextSourcePolicy`, `WebEvidenceConfig`, structured synthesis types

Used with **`executionPipeline`** and the **`synthesized-context`** PRE step. Import from **`@exellix/ai-tasks`** (and use **`SYNTHESIZED_CONTEXT`** for the PRE step `type` string):

```typescript
import type {
  ExecutionPhase,
  ExecutionStep,
  ExecutionStrategyInvocation,
  SynthesisConfig,
  ContextSourcePolicy,
  WebEvidenceConfig,
  SynthesisInput,
  SynthesizedPromptPayload,
  SynthesizedContext,
  SynthesizedItem,
  ContextSynthesizer,
  XynthesizedMemory,
  SmartInputConfig,
  SmartInputRenderOptions,
  SmartInputRenderResult,
  RunTaskSmartInput,
} from "@exellix/ai-tasks";
import { SYNTHESIZED_CONTEXT } from "@exellix/ai-tasks";

// ExecutionPhase = "pre" | "main" | "post"
// ExecutionStep: { phase, type: SYNTHESIZED_CONTEXT | "direct" | "audit" | "polish", config? }
// SynthesisConfig: … synthesisOutputFormat?: "markdown" | "structured", questionPath?, getQuestion?, structuredMaxItemsPerSide?, …
// ContextSourcePolicy: "auto" | "narrix-web" | "narrix-web-memory" | "memory-web" | "memory-only" | "narrix-only" | "narrix+memory"
// WebEvidenceConfig: preferCleanContent?, maxSources?, dedupeByUrl?, maxTotalChars?
```

Semantics and the full policy table: [Execution pipeline and synthesized-context](#execution-pipeline-and-synthesized-context-pre-step).

### `ExecutionType`

```typescript
enum ExecutionType {
  DIRECT = "direct"
}
```

The package also exports the constant `NARRIX_THEN_DIRECT = "narrix-then-direct"` for use with `executionType` when running Narrix first, then the executor. See [Narrix then execute](#narrix-then-execute).

### `NarrixScope`

Optional filter for which signals and stories from Narrix are written to task memory (used with `executionType: "narrix-then-direct"`):

```typescript
interface NarrixScope {
  includeSignals?: string[];   // Signal codes to keep (allowlist)
  excludeSignals?: string[];   // Signal codes to drop (blocklist; ignored when includeSignals is set)
  includeStories?: string[];   // narrativeTypeIds to keep (allowlist)
  excludeStories?: string[];   // narrativeTypeIds to drop (blocklist; ignored when includeStories is set)
}
```

### `NarrixPreProcessorConfig`

Exported type for **`RunTaskRequest.narrix`**. Includes **`datasetId`**, optional **`attachToField`**, and reserved fields. It carries **no** web-scope fields. See [NARRIX task-level pre-processor](#narrix-task-level-pre-processor) and [documenations/web-scoping-in-ai-tasks.md](./documenations/web-scoping-in-ai-tasks.md).

```typescript
import type { NarrixPreProcessorConfig } from "@exellix/ai-tasks";
```

### `RunTaskModelConfig`

Required on every `runTask()` — three slots plus optional tuning (no `maxTokens`, no legacy `model` / `xynthesisModel`):

```typescript
import type { RunTaskModelConfig } from "@exellix/ai-tasks";

interface RunTaskModelConfig {
  preActionModel: string;   // ai-profiles alias (xynthesis PRE)
  skillModel: string;       // alias or concrete id (ai-skills MAIN)
  postActionModel: string;  // ai-profiles alias (xynthesis POST)
  temperature?: number;
  topP?: number;
  reasoningEffort?: OptimixerReasoningEffort;
  frequencyPenalty?: number;
  presencePenalty?: number;
}
```

**Example:**
```typescript
const modelConfig: RunTaskModelConfig = {
  preActionModel: "cheap/default",
  postActionModel: "cheap/default",
  skillModel: "cyber",
  temperature: 0.7,
  topP: 0.9,
  reasoningEffort: "medium",
};

const result = await runTask({
  skillKey: "tasks/analysis",
  executionType: ExecutionType.DIRECT,
  input: { data: "analyze" },
  modelConfig
});
```

**Note:** Root `RunTaskRequest.modelConfig` uses canonical slots (`RunTaskModelConfig`: `preActionModel`, `skillModel`, `postActionModel`). Profile/choice keys are validated at `runTask` entry and resolved at each xynthesis / ai-skills call boundary via `@x12i/ai-profiles` v2. See [Model profile aliases](#model-profile-aliases-x12iai-profiles) and [`MODEL-CONFIGURATION.md`](MODEL-CONFIGURATION.md).

### `JobHistory`, `TaskHistory`, and `ExecutionHistory`

History objects for managing execution context across workflows:

```typescript
import type { JobHistory, TaskHistory, ExecutionHistory } from "@exellix/ai-tasks";

// JobHistory: Contains history of all previous task results in the job
const jobMemory: JobHistory = {
  // Previous task results
};

// TaskHistory: Contains history of skills executed up to this task
const taskMemory: TaskHistory = {
  // Previous skill executions
};

// ExecutionHistory: Contains history of execution context (execution-level memory)
const executionMemory: ExecutionHistory = {
  // Execution state, intermediate results, execution-level variables
  previousAttempts: 2,
  lastError: "timeout",
  intermediateResults: { step1: "completed" }
};
```

- `JobHistory` and `TaskHistory` are re-exported from `@x12i/execution-memory-manager` for convenience
- `ExecutionHistory` is defined in this package as `Record<string, any>` for execution-level memory

See [HISTORY-OBJECTS.md](./HISTORY-OBJECTS.md) for detailed documentation on history objects and [HISTORY-OBJECTS-DOWNSTREAM.md](./HISTORY-OBJECTS-DOWNSTREAM.md) for information on how they flow downstream.

---

## Content Registry Structure (v2.0.2+)

To ensure robust resolution with `@x12i/ai-gateway` v7.0.0+, it is recommended to provide both `.instructions` and `.prompt` files for each skill in your `.metadata/skills` directory.

- **`{skillId}.instructions`**: Contains the system instructions (persona, constraints, output format).
- **`{skillId}.prompt`**: Contains the user prompt template (e.g., `{{input}}`). With gateway template **v4**, `{{input}}` is supplied from merged working memory (see [Gateway template rendering (v4)](#gateway-template-rendering-v4)); use **`templateRenderOptions.subPathSearch`** when templates need paths like **`{{execution.summary}}`** backed by nested task input.

See [SKILL-CONTENT-GUIDE.md](./SKILL-CONTENT-GUIDE.md) for a detailed migration and implementation guide.

---

## Environment Variables

See `.env.example` for complete configuration. Key variables:

**Used by ai-tasks:**

- `MONGO_LOGS_DB` - [OPTIONAL] Default database for activity logs (defaults to `logs-db`), passed as `bindingDefaultsDb` to the skills client.

**POST step (shared):**

- `POST_STEP_MODEL` - [OPTIONAL] Fallback model for all POST step LLM calls when no step-specific env is set.
- `POST_STEP_TIMEOUT_MS` - [OPTIONAL] Fallback timeout in ms for POST step LLM calls (default `30000`).

**Audit POST step:**

- `AUDIT_MODEL` - [OPTIONAL] Model for audit evaluator (→ `POST_STEP_MODEL` → gateway default).
- `AUDIT_SYNTHESIS_MODEL` - [OPTIONAL] Model for synthesis merge when `selectionStrategy: "synthesis"` (→ `POST_STEP_MODEL` → MAIN step model).
- `AUDIT_TIMEOUT_MS` - [OPTIONAL] Timeout per audit LLM call (→ `POST_STEP_TIMEOUT_MS` → `30000`).
- `AUDIT_TEMPLATES_PATH` - [OPTIONAL] Base path for audit templates (default `<project>/templates/post-steps/audit`).

**Polish POST step:**

- `POLISH_MODEL` - [OPTIONAL] Model for polish LLM (→ `POST_STEP_MODEL` → gateway default).
- `POLISH_TIMEOUT_MS` - [OPTIONAL] Timeout per polish call (→ `POST_STEP_TIMEOUT_MS` → `30000`).
- `POLISH_TEMPLATES_PATH` - [OPTIONAL] Base path for polish templates (default `<project>/templates/post-steps/polish`).

**Required for content and providers (validated by ai-skills):**

- `GITHUB_TOKEN` - [REQUIRED] GitHub token for content registry access
- `GITHUB_REPO_URL` - [REQUIRED] Content registry repository URL
- `OPENAI_API_KEY` or `GROK_API_KEY` - [REQUIRED] AI provider key (or `OPEN_ROUTER_KEY` / `OPENROUTER_API_KEY` if ai-skills accepts OpenRouter-only)

**Downstream (used by ai-skills → ai-gateway → ai-provider-router, ai-activities-tracking):** ai-tasks does not read these; they affect LLM and activity tracking when using this package.

- `OPEN_ROUTER_KEY` or `OPENROUTER_API_KEY` - [OPTIONAL] Use OpenRouter for LLM calls; no provider registration needed when set.
- `MONGO_LOGS_COLLECTION` - [OPTIONAL] Activities collection (default in ai-activities-tracking: `cognitive-activities`).
- `MONGO_BAD_REQUESTS_COLLECTION` - [OPTIONAL] Bad-requests collection (default: `ai-bad-requests`).

**Synthesis (synthesized-context PRE step):**

- `SYNTHESIS_MODEL` - [OPTIONAL] Default model for synthesis (e.g. `gpt-5-nano`).
- `SYNTHESIS_TIMEOUT_MS` - [OPTIONAL] Timeout for synthesis call (default 30000).
- `SYNTHESIS_MAX_OUTPUT_LENGTH` - [OPTIONAL] Max length of synthesis output (characters).
- `SYNTHESIS_TEMPLATES_PATH` - [OPTIONAL] Base path for `templates/synthesis/system.md` and `user.txt` (default: project root).

**Other:**

- `MONGODB_URI` - [OPTIONAL] MongoDB for context enrichment
- `CONTENT_REGISTRY_LOCAL_PATH` - [OPTIONAL] Local registry path
- `USE_NARRIX_INGEST` - [OPTIONAL] Set to `"1"` to enable the Narrix pipeline for `skills/skill.local:narrixRun` (record/text/docs/chat). When unset, the handler returns `NARRIX_DISABLED`.
- `NARRIX_DEBUG` - [OPTIONAL] Set to `"1"` to log datasetId, medium, packId, entityKind, signal codes, and narrativeTypeIds when the Narrix handler runs.
- `TAVILY_API_KEY` - [OPTIONAL] Required when using **`webScope`** PRE units (via `@exellix/web-scope` → `@x12i/web-queries` + `@x12i/search-adapter` / Tavily). When missing or invalid, `executionMemory.webContext` reflects `ok: false`; the task still completes.
- `MONGO_URI`, `EXELLIX_NAME`, `MEMORIX_KNOWLEDGE_DB` - [OPTIONAL] Memorix TTL caching and synthesized-knowledge persistence for web scope (via `@x12i/web-scoper-memorix`).
- `WEB_CONTEXT_MARKDOWN_MAX_CHARS` - [OPTIONAL] Positive integer: max Unicode length of the **Web sources** markdown block appended to task context / synthesis source material (tail truncated with a marker). Omit for no downstream cap (upstream **`maxTotalWebContextChars`** / **`maxSnippetCharsPerSource`** still apply in the scoper).

For a concise breakdown of what ai-tasks uses vs downstream, see [documenations/downstream-environment.md](./documenations/downstream-environment.md).

---

## Examples

### Basic Usage

```typescript
import { WorexClientSkills } from "@exellix/ai-skills";
import { WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";

const skills = new WorexClientSkills();
const executor = { /* get from skills client */ };
const tasks = new WorexClientTasks(skills, executor);

const result = await tasks.runTask({
  skillKey: "tasks/security-risk-summary",
  executionType: ExecutionType.DIRECT,
  input: { assetId: "a-123" },
  variables: { orgName: "MyOrganization" },
  modelConfig: {
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "openai/gpt-5",
    temperature: 0.7
  }
});

console.log(result.flexMd.payloads);
```

### With Memory Management

```typescript
import { WorexClientSkills } from "@exellix/ai-skills";
import { WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";
import type { JobHistory, TaskHistory, ExecutionHistory } from "@exellix/ai-tasks";

const skills = new WorexClientSkills();
const executor = { /* get from skills client */ };
const tasks = new WorexClientTasks(skills, executor);

const jobMemory: JobHistory = {
  // Previous task results
};

const taskMemory: TaskHistory = {
  // Previous skill executions
};

const executionMemory: ExecutionHistory = {
  previousAttempts: 1,
  intermediateResults: { analysis: "in-progress" }
};

const result = await tasks.runTask({
  skillKey: "tasks/professional-decision",
  executionType: ExecutionType.DIRECT,
  input: { scenario: "Should we migrate to cloud?" },
  variables: { orgName: "EnterpriseCorp" },
  jobMemory,
  taskMemory,
  executionMemory,
  jobId: "job-456"
});
```

### With Model Configuration

```typescript
import { WorexClientSkills } from "@exellix/ai-skills";
import { WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";

const skills = new WorexClientSkills();
const executor = { /* get from skills client */ };
const tasks = new WorexClientTasks(skills, executor);

// Configure model per-request (@exellix/ai-skills correlation ids required)
const result = await tasks.runTask({
  skillKey: "tasks/analysis",
  agentId: "agent-1",
  jobTypeId: "analysis-job",
  taskTypeId: "analysis-task",
  executionStrategies: [],
  executionType: ExecutionType.DIRECT,
  input: { data: "analyze this" },
  modelConfig: {
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "gpt-5",
    temperature: 0.7,
    topP: 0.9,
  },
});

// Or using the builder
import { TaskRequestBuilder } from "@exellix/ai-tasks";

const request = new TaskRequestBuilder()
  .withSkillKey("tasks/analysis")
  .withInput({ data: "analyze this" })
  .withAiSkillsCorrelation("agent-1", "analysis-job", "analysis-task")
  .withExecutionStrategies([])
  .withModelConfig({
    preActionModel: "cheap/default",
    postActionModel: "cheap/default",
    skillModel: "gpt-5",
    temperature: 0.5,
  })
  .build();

const result2 = await tasks.runTask(request);
```

### With Graph Execution Context

```typescript
import { WorexClientSkills } from "@exellix/ai-skills";
import { WorexClientTasks, ExecutionType } from "@exellix/ai-tasks";

const skills = new WorexClientSkills();
const executor = { /* get from skills client */ };
const tasks = new WorexClientTasks(skills, executor);

// Execute a task within a graph workflow
const result = await tasks.runTask({
  skillKey: "tasks/security-risk-summary",
  executionType: ExecutionType.DIRECT,
  input: { assetId: "a-123" },
  variables: { orgName: "TechCorp" },
  modelConfig: {
    model: "gpt-5",
    temperature: 0.7
  },
  jobId: "job-123",
  agentId: "agent-1",
  graphId: "workflow-data-processing",  // Graph identifier for activity tracking
  nodeId: "extract-emails-node"         // Node identifier for activity tracking
});

console.log(result.flexMd.payloads);
// Graph and node context are automatically passed through to activity tracking
```

---

## Activix — task activity tracking

`@x12i/activix` (**8.4+**) provides multi-phase activity tracking: each `runTask()` invocation emits several phase records (`local`, `narrix`, `pipeline_pre`, `direct`, `audit`, `polish`, `narrix_then_direct`). All phase records share the same `correlationId` (runtime `taskId`), so you can group them into one logical task run.

**Operational checklist:** [documenations/activix.md](./documenations/activix.md) · **Full reference:** [docs/activix.md](./docs/activix.md)

### How wiring works (Activix 8.4+)

ai-tasks **always calls Activix lifecycle APIs** on AI activity phases when Mongo is configured. **Activix decides whether rows are persisted** — do not branch in application code on a local write toggle.

```
runTask() phase  →  startRecord / completeRecord / failRecord
                              ↓
              activix-collections legend state
              track → Mongo write    |    off → same API, skip write
```

| Layer | Owns |
|---|---|
| **ai-tasks** | Construct Activix when `MONGO_URI` or `MONGO_LOGS_URI` is set; call lifecycle APIs per phase; pass `runContext`, `outer`, domain metadata |
| **Activix** | `recordId`, `status`, `startTime`, `endTime`, `duration`; read legend `state` and skip or perform Mongo writes |
| **Ops** | Toggle persistence via `activix-collections` (`state: track \| off`) or `setCollectionTrackingState()` — no redeploy required |

Lifecycle fields (`status`, timestamps, primary key) are **never** set by ai-tasks; Activix manages them on every write.

### Enable / disable

**Client construction** (whether phase APIs are called):

- **On by default** when `MONGO_URI` or `MONGO_LOGS_URI` is set.
- **Opt out entirely:** `ACTIVIX_ENABLED=false` — no Activix client, no API calls (zero overhead for local dev/CI).
- **`ACTIVIX_ENABLED=true` is no longer required** to turn tracking on.

**Mongo persistence** (whether rows reach the database):

- Controlled by Activix collection legend **`state`** in `activix-collections` (default **`track`**).
- Set **`off`** to skip writes while keeping the same API surface: `await ax.setCollectionTrackingState('task-activities', 'off')`.
- Legend state is cached (default TTL **600 s**); override with `ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC`.

All Activix failures are **non-fatal** — a MongoDB outage never surfaces to the `runTask()` caller.

### Configuration

| Variable | Default | Description |
|---|---|---|
| `MONGO_URI` / `MONGO_LOGS_URI` | — | Required to construct Activix (first set wins) |
| `ACTIVIX_ENABLED` | _(unset)_ | Set to `false` to opt out of client construction |
| `ACTIVIX_COLLECTION` | `task-activities` | Dev/CI override only; package hard-codes `task-activities` in source |
| `ACTIVIX_STALE_TTL_MS` | `300000` | TTL (ms) before a stuck `started` record is marked `timeout` |
| `ACTIVIX_COLLECTION_TRACKING_STATE_TTL_SEC` | `600` | Legend `state` cache TTL (Activix 8.4+) |
| `ACTIVIX_LOGS_LEVEL` | _(see below)_ | Injected Activix logger threshold (`@x12i/logxer`, prefix **`ACTIVIX`**). Falls back to `ACTIVIX_LOG_LEVEL`, then `warn`. Values: `verbose` \| `debug` \| `info` \| `warn` \| `error` \| `off` / `silent` / `none`. |
| `ACTIVIX_LOG_LEVEL` | `warn` | Legacy alias when `ACTIVIX_LOGS_LEVEL` is unset |
| `AI_TASKS_LOGS_LEVEL` | `warn` | ai-tasks diagnostics threshold (`warnCode` + `.metadata/log-diagnostics.json`). Legacy alias: `AI_TASKS_LOG_LEVEL`. |

**Debug tooling (logxer 4.4.2):** **`getAiTasksJobLogs({ jobId, level, limit })`** queries in-process structured log envelopes captured during **`runTask()`**. Correlation ids are set via **`runAiTasksWithLogContext`** / **`patchRunTaskLogContext`**.

**Query API:** **`getTaskActivities(taskId)`** returns all phase records for one run (by `correlationId`).

### What is recorded

Each record in the `task-activities` collection captures:

- `correlationId`, `phase` — join key for grouping phase records into one `runTask()` invocation
- `runContext` — correlation envelope: `sessionId` matches `correlationId`; `taskId`, `skillId`, graph/node ids live here (not a top-level `identity` blob)
- `outer` — required I/O tier (`input` / `output` / `metadata`); minimal placeholders until completion metadata is merged
- `skillKey`, `executionType`, `jobId`, `agentId`, `graphId`, `nodeId` — from the request
- `hasPipeline`, `hasNarrix`, `hasNarrixInput` — execution path flags
- `status`, `startTime`, `endTime`, `duration` — **auto-managed by Activix**
- `instructionVersion`, `durationMs`, `stepCount` — from the response on success (phase-dependent)
- `error` / `errorMessage`, `errorCode` — on failure

### Stale record cleanup

Crashed or hung tasks leave records stuck in `started`. Call `ax.markStaleRecords()` from a periodic job (e.g. every 5 minutes) in the host process to flip them to `timeout`.

---

## Development Notes

- This is a **private package**; we optimize for reuse and speed.
- `@exellix/ai-skills` is treated as the shared helper + execution layer.
- Naming leakage is explicitly acceptable (private monorepo + private packages).
- Task-level enrichment always uses `level: 'task'` (not `'skill'`).
- The executor must be a "no-enrichment execute primitive" to avoid double-enrichment.

---

## Troubleshooting

### "Content not found" Error

- Verify skill key format: `"tasks/security-risk-summary"` (not `"security-risk-summary"`)
- Check content registry contains the task
- Verify `GITHUB_TOKEN` and `GITHUB_REPO_URL` are set correctly

### "Repository not accessible"

- Verify `GITHUB_TOKEN` has `repo` scope for private repos
- Check `GITHUB_REPO_URL` points to an existing repository

### "Environment validation failed"

- Ensure at least one AI provider key is set (`OPENAI_API_KEY` or `GROK_API_KEY`)
- Verify `GITHUB_TOKEN` and `GITHUB_REPO_URL` are set
- Check `.env` file is loaded (if using `dotenv`)

---

## Development

```bash
# Install dependencies (requires @exellix scope registry/auth for narrix packages)
npm install

# Build
npm run build

# Run tests (all narrix tests under test/narrix/; requires build first)
npm test

# Run full Narrix integration tests (record, text, docs, chat + 14 record scenarios)
npm run test:with-narrix-ingest
# Bash/macOS/Linux alternative: USE_NARRIX_INGEST=1 npm test
# PowerShell: $env:USE_NARRIX_INGEST='1'; npm test

# Run real E2E for intermediateSteps (real executor + real skill test-intermediate-steps; requires LLM + gateway env like other E2Es)
# Also requires Catalox/Firebase: FIREBASE_PROJECT_ID and GOOGLE_SERVICE_ACCOUNT_BASE64 (tests load repo-root `.env` first; skip gate requires both to be set).
npm run test:e2e:intermediateSteps
# Bash alternative: RUN_INTERMEDIATE_STEPS_E2E=1 npm test

# Run real E2E for synthesized-context (requires a provider API key in .env)
# Keys recognized by the gate: OPENROUTER_API_KEY, OPENAI_API_KEY, OPENAI_KEY, OPEN_ROUTER_KEY, GROK_API_KEY.
# Optional: SYNTHESIS_MODEL, LLM_MODEL_STRONG, LLM_MODEL_NORMAL, LLM_MODEL, POST_STEP_MODEL (else gateway default).
# Runs three opt-in tests in test/synthesis/e2e-synthesis-real.test.ts:
#   (1) default markdown synthesis PRE step + main skill,
#   (2) runStructuredSynthesisGatewayCall (real gateway JSON → parse → buildSynthesizedContextMarkdown),
#   (3) synthesisOutputFormat: "structured" PRE + main skill (memory-only source + input.question).
# The synthesis model must follow JSON instructions for (2) and (3). Uses ~3 synthesis + ~2 main LLM calls.
RUN_SYNTHESIS_E2E=1 npm test
# Or: npm run test:e2e:synthesis
#
# Xynthesis + Mongo / Activix + sidekick hardening: documenations/xynthesis-upstream-fixes-checklist.md

# Development mode
npm run dev
```

**Narrix tests:** When `USE_NARRIX_INGEST` is not set, only the "feature flag off" test runs; the rest are skipped. Use **`npm run test:with-narrix-ingest`** (or set the env var per shell above) to run contract and integration tests; live catalog tests also need `.archive/packages/…` (see **BREAKING-CHANGES-NARRIX-V5.md**). Optional: `NARRIX_DEBUG=1` for verbose handler logging.

**NARRIX task-level pre-processor tests:** `test/narrix/preProcessorAdapter.integration.test.ts` (adapter produces record from jobMemory/executionMemory/input shapes), `test/narrix/buildNarrixAttachment.test.ts` (attachment from runner result with/without passes), and `test/narrix/preProcessorE2E.test.ts` (throw when `narrix` set but no record; e2e handler receives `ctx.executionMemory._narrix` when ingest is enabled and the narrix seed archive is present).

---

## License

ISC

## Repository

[GitHub](https://github.com/exellix/ai-tasks)

## Related Packages

- [`@exellix/ai-skills`](https://github.com/exellix/ai-skills) — MAIN skill execution (`runSkill`, Catalox catalog, gateway invoke; **≥ 6.4.0** / gateway **≥ 10.1.0**)
- [`@exellix/xynthesis`](https://github.com/exellix/xynthesis) — PRE/POST LLM hops (`executeXynthesisAction`, sidekick actions; **≥ 4.5.0**)
- [`@x12i/ai-gateway`](https://github.com/athenices/ai-gateway) — LLM gateway for MAIN skill (transitive via ai-skills ≥6.4, **≥ 10.1.0**)
- [`@x12i/execution-memory-manager`](https://github.com/athenices/execution-memory-manager) — Memory management utilities

## Related Documentation

- [@exellix/ai-skills — UPGRADING_AI_GATEWAY_10.md](../ai-skills/docs/UPGRADING_AI_GATEWAY_10.md) — **gateway 10.x migration** (billing ownership, required `model` + `maxTokens`, removed defaults, Activix persistence, `error.metadata`)
- [@exellix/ai-skills — MODEL_INVOKE_CONTRACT.md](../ai-skills/docs/MODEL_INVOKE_CONTRACT.md) — MAIN wire model, Optimixer caps, gateway billing ownership
- [@exellix/ai-skills — AI_GATEWAY_INVOKE_EXECUTION_METADATA.md](../ai-skills/docs/AI_GATEWAY_INVOKE_EXECUTION_METADATA.md) — gateway invoke metadata (`costUsd`, rejection `metadata`, routing)
- [@exellix/ai-skills — SKILLS_MANAGER_CLIENT_GUIDE.md](../ai-skills/docs/SKILLS_MANAGER_CLIENT_GUIDE.md) — studio vs runtime boundaries
- [documenations/intermediate-steps.md](./documenations/intermediate-steps.md) - Structured intermediate results for combined/multi-step tasks (`intermediateSteps` response field)
- [HISTORY-OBJECTS.md](./HISTORY-OBJECTS.md) - Comprehensive guide to `JobHistory`, `TaskHistory`, and `ExecutionHistory` objects
- [HISTORY-OBJECTS-DOWNSTREAM.md](./HISTORY-OBJECTS-DOWNSTREAM.md) - How history objects flow downstream through the execution pipeline
- [GRAPH-EXECUTION-SUPPORT.md](./GRAPH-EXECUTION-SUPPORT.md) - Graph execution context and activity tracking
- [MODEL-CONFIGURATION.md](./MODEL-CONFIGURATION.md) - Complete guide to model configuration in `runTask()`
- [NARRIX-FOUR-OBJECTS-AND-USE-CASES.md](./NARRIX-FOUR-OBJECTS-AND-USE-CASES.md) - The four Narrix input objects (record, text, docs, chat), payload shapes, and use cases (local narrixRun, narrix-then-direct, normalize, validate)
- [NARRIX-STATUS-REPORT.md](./NARRIX-STATUS-REPORT.md) - Narrix pipeline status: unified handler (record/text/docs/chat), resolver, runners, and tests
- [documenations/web-scoping-in-ai-tasks.md](./documenations/web-scoping-in-ai-tasks.md) - Web scoping in the task SDK
- [documenations/run-task-execution-flow.md](./documenations/run-task-execution-flow.md) - Step-by-step current `runTask()` behavior and flow diagram
- [documenations/schemas/README.md](./documenations/schemas/README.md) - JSON Schema / OpenAPI 3.1 fragments for `outputValidation` and PRE synthesis `executionMemory` fields
- [documenations/core-runtime-tokens-and-strategies.md](./documenations/core-runtime-tokens-and-strategies.md) - Core token handling and strategy behavior overview
- [documenations/synthesized-context-guide.md](./documenations/synthesized-context-guide.md) - User-facing guide for `synthesized-context` PRE-step behavior and controls
- [documenations/task-core-and-core-aware-synthesis.md](./documenations/task-core-and-core-aware-synthesis.md) - Template core token model and structured synthesis behavior
- [documenations/synthesis-invocation-notes.md](./documenations/synthesis-invocation-notes.md) - Notes on synthesis call behavior, cost, and operational implications
- [documenations/activix.md](./documenations/activix.md) - Activix integration: lifecycle tracking, collection legend `state` (Activix 8.4+), record schema, env config, execution path mapping, stale cleanup
- [documenations/downstream-test-runtime-teardown-cleanup.md](./documenations/downstream-test-runtime-teardown-cleanup.md) - Downstream `ai-skills` lifecycle cleanup note for open handles/process-exit hygiene in tests

---
_Source: https://npm.io/package/@exellix/ai-tasks · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
