Imported from cinatra-ai/cinatra (
packages/agents/AGENTS.md). Install upstream withnpx skills add cinatra-ai/cinatra --skill agents. Copyright stays with the author.
@cinatra-ai/agents
Subtree-specific guidance for the agents package.
WayFlow hot-reload
Three exports gate WayFlow runtime sync after publish/install:
triggerWayflowReload()— POSTs to{WAYFLOW_BASE_URL}/.internal/reload-agentswithX-Cinatra-Bridge-Token. Never throws. ReturnsReloadResult. 10sAbortControllertimeout. Trailing slashes stripped from base URL. Response shape validated.materializeAgentPackageToDisk(input)— atomically writes the extracted tarball's runtime files (cinatra/oas.json,skills/,package.json,README.md) to<agentInstallDir>/<vendor>/<slug>/. Path-traversal hardened: strict@vendor/slugregex +resolve().startsWith(agentsRoot)containment. Symlinks in the extracted tree are rejected via anlstatwalk beforefs.cp. Returns{ materialized: true, targetDir, priorDirBackup, wasReinstall }or{ materialized: false, reason }.withInstallLock(packageName, fn)— per-package re-entrant async lock. Tracked vianode:async_hooksAsyncLocalStorage. Callers in different files (e.g.extension-handler.ts) can hold an outer lock spanning install + skill-registration + compensation; nestedwithInstallLockcalls frominstall-from-package.tsdetect the held key and run inline without re-acquiring.
triggerWayflowReload is fanned out across four ordered sites — install/publish at the top, then uninstall, then preflight auto-recovery at the bottom of the request lifecycle. installAgentFromPackage itself does NOT reload (avoids N reloads for an N-dep tree).
installAgentPackageWithDependencies— once at the end of the full dep tree install.agent_source_publishMCP handler — once after publish + DB sync + origin freeze. Gated on install success.extension-handler.ts::uninstall— once after DB delete + disk-dir removal. Reaches bothextensions_uninstallandextensions_force_delete.preflightWayflowAgent— auto-recovery: on 404 it triggers a reload + re-probes once before surfacing the error.
Reload failure is non-fatal at every site: durable side-effects (Verdaccio, DB, on-disk tarball, DB delete) stay committed. Failure surfaces as installedPendingReload: true + wayflowReload: { ok: false, reason } on the publish/install handler responses, or as a WAYFLOW_AGENT_NOT_REGISTERED result with diagnostic reason from preflight.
Preflight success after recovery returns { code: "OK", recoveredViaReload: true }. Preflight failure after recovery names the three likely root causes (WayFlow image without hot-reload support, tarball missing cinatra/oas.json, parse failure).
Full design: wayflow-runtime-reload.
WayFlow timeout policy
All blocking sendTask calls to WayFlow share a 24h ceiling end-to-end. The constants and helpers live in packages/agents/src/wayflow-url.ts:
WAYFLOW_A2A_TIMEOUT_MS = 86_400_000— AbortSignal ceiling. Pass ascreateExternalA2AClient({ timeoutMs: WAYFLOW_A2A_TIMEOUT_MS }).WAYFLOW_UNDICI_TIMEOUT_MS = 86_400_000— undiciheadersTimeout+bodyTimeoutfor the long-lived dispatcher.AGENT_RUN_TIMEOUT_MAX_SECONDS = 86_400— max value accepted byagent_run.timeoutSeconds(Zod schema + runtime validation inmcp/handlers.ts).createWayflowFetch()— builds afetchwhose underlying undici Agent has longheadersTimeout/bodyTimeout. Required at everycreateExternalA2AClientcall targeting a local WayFlow endpoint —globalThis.fetchuses undici's 300s default which silently kills 24h AbortSignal calls.
Canonical call pattern:
import {
WAYFLOW_A2A_TIMEOUT_MS,
createWayflowFetch,
resolveWayflowUrl,
} from "@cinatra-ai/agents/wayflow-url";
const client = await createExternalA2AClient({
agentUrl: resolveWayflowUrl(packageName),
timeoutMs: WAYFLOW_A2A_TIMEOUT_MS,
fetchImpl: createWayflowFetch(),
});
The Python side (docker/wayflow/agent_loader.py) mirrors the 24h ceiling on ApiCallStep._execute_request, _BLOCKING_REQUESTS_MAX_TIME_SECONDS, and the pyagentspec A2ASessionParameters / A2AConnectionConfig timeout defaults (mutated via model_fields[].default = X + model_rebuild(force=True)).
Operator escape hatch: Cinatra does NOT configure an explicit BullMQ job timeout. The 24h ceiling IS the practical upper bound for a single in-flight call. Tighter caps go via agent_run.timeoutSeconds (1..86400) or explicit timeout on individual ApiNodes in OAS.
Typed production — cinatra.produces is the ONLY path (cinatra#1788, epic #1785)
An agent declares the typed object/artifact output it emits via
package.json#cinatra.produces: { extension, objectTypeId? }[] — the ONLY path
for typed agent output. Each entry names a REQUIRED artifact-kind dependency
(extension) and OPTIONALLY the exact @scope/pkg:local-id type it produces
(objectTypeId — the #1452 discriminator). The type must exist at INSTALL time:
every entry MUST resolve to an artifact-kind package in the agent's REQUIRED
transitive install closure — a cinatra.dependencies edge with kind:"artifact",
requirement:"required", non-peer, pinned by an exact version or a satisfiable
semver range — whose manifest DECLARES the referenced cinatra.artifact.objectTypes
claim (the exact objectTypeId when the entry carries one).
Enforced FAIL-CLOSED at BOTH publish (verdaccio/client.ts) and install
(install-from-package.ts), against the PLANNED closure BEFORE any template or
DB write — the contract logic is evaluateTypedProducesContract /
resolveTypedProducesContract in verdaccio/package-contract.ts. A violation
(an entry naming a non-required-dependency extension, or an objectTypeId no
closure dependency claims) fails the publish and refuses the install with a
precise error naming the missing claimant/claim — a human-present failure at
install time, never a runtime surprise. A git-ref-pinned artifact dependency
cannot prove its claim through the registry, so a typed (objectTypeId) entry
against one fails closed; pin the artifact dependency by exact version or a
satisfiable semver range.
RETIRED — do not reintroduce: runtime dynamic-type minting. There is no
producesObjectTypes, no outputs[*].cinatra.object_type OAS annotation, and no
install-time dynamic-type mint — the dynamic-types engine was torn down
end-to-end (epic #1785 entry 95; #1793). Typed output exists ONLY by installing
the artifact-kind extension that CLAIMS the type.
Removed features (do not reintroduce)
These orphaned, fully-unreferenced source clusters are intentionally absent from src/. Do not recreate these — if a future need arises, design fresh against current patterns rather than resurrecting the deleted files:
- Pipeline Composition UI —
pipeline-composition-panel.tsx,pipeline-edge.tsx,pipeline-node.tsx,pipeline-composition-derive.ts,object-category-icon.tsx(+ their__tests__). A read-only orchestrator-template pipeline-flow visualization that was never wired into any screen or entry point. There is noagentDetail/screens.tsxconsumer; it was dead from introduction. - Stale agent-detail UI bits —
review-workspace.tsx,run-again-button.tsx,run-history-list.tsx,export-button.tsx, plus the unreferenced helpersagentic-messages.ts,audit-projections.ts,presentation-parser.ts,ref-resolver.ts,contact-scope-renderer.tsx,source-package-layout.ts(+ relevant__tests__). Superseded by the current agent run/detail screens;source-package-layout.ts's canonical-path responsibility lives inresolveAgentInstallDir()+ inline"cinatra-ai"joins.
verdaccio/vendor-types.d.ts was deliberately KEPT — it is a load-bearing ambient declare module "pacote" (and libnpmpublish) that pnpm typecheck requires because verdaccio/client.ts imports pacote, which ships no types. Do not delete it as "unreferenced"; ambient .d.ts modules are consumed by the type system, not by import.
Integration tests
Files that require a real isolated Postgres schema MUST end in .integration.test.ts.
- The default
pnpm test(alias forvitest run) excludes them viatest.excludeinvitest.config.ts. - Run them locally with:
afterCINATRA_TEST_DB_URL=<url> pnpm test:integrationcinatra setup branchhas provisioned an isolatedcinatra_<slug>schema. The script forwardsCINATRA_TEST_DB_URLintoSUPABASE_DB_URLfor the vitest run and exits with a clear error if the variable is unset. - CI runs
pnpm test:integrationaftercinatra setup branchin.github/workflows/. - When in doubt — if the test inserts into
cinatra.*tables with FK constraints — name it.integration.test.ts.
Vitest pool: forks and cross-file mock leaks
packages/agents/vitest.config.ts sets pool: "forks". This is required, not optional.
Why forks (not threads)
Several tests in this package vi.mock(...) modules that other test files import without mocking — most often shared infrastructure like ../mcp/schemas, @/lib/auth, or @cinatra-ai/skills subpaths. Under the default pool: "threads", vitest worker threads share a Node module cache. A vi.mock factory installed by file A leaks into file B's import graph when both run in the same worker, producing failures that look like "the mocked module returned undefined" or "mockResolvedValueOnce isn't taking effect" — but only when the suite is run together. The hallmark symptom is "passes in isolation, fails in the suite".
pool: "forks" runs each test file in a dedicated child process, so the module cache is rebuilt from scratch per file. Slower, but the only correct choice here.
Diagnosing a suspected leak
If a test passes via pnpm exec vitest run path/to/single.test.ts but fails as part of pnpm test:
- Re-run the full suite with
--pool=threadsto confirm the failure is leak-shaped (it usually disappears under forks). - Identify which earlier file mocks the same module — the
__mocks__/index plus a quickgrep -r "vi.mock(\"<module>\"" src/__tests__is the fastest path. - Either narrow the offending
vi.mockto that file'sbeforeEach+vi.unmockinafterAll, or move the shared stub intosrc/__tests__/__mocks__/and wire it via aresolve.aliasentry invitest.config.ts.
Never vi.mock a module that other files import without mocking unless you scope the mock to the test's own beforeEach/afterAll lifecycle.
The __mocks__/ stub pattern
Test-only stubs live in src/__tests__/__mocks__/ and are wired via resolve.alias in vitest.config.ts. Existing examples:
__mocks__/modelcontextprotocol-server.ts— the published npm@modelcontextprotocol/server(exact2.0.0); its dist re-imports its own./_shimssubpath, which vite import-analysis cannot follow through a bare-package alias, so vitest needs an explicit alias to this stub.__mocks__/auth.ts,__mocks__/mcp-server.ts,__mocks__/primitive-handlers.ts,__mocks__/mcp-instructions.ts— break heavy host-app import chains that pull inbetter-auth, the full connector tree, or React UI from server code.__mocks__/toast.ts—sonnerresolves as a CJS shim under vitest where the namedtoastexport isundefined; the real@/lib/toastdoes_toast.promise.bind(_toast)at load time and crashes. The stub returns inert no-op functions.__mocks__/server-only.ts— replaces theserver-onlyimport-time guard.__mocks__/verdaccio-config.ts— re-exports the lower-layer registries function so test mocks of@cinatra-ai/registriescascade naturally.
Adding a new mock when a third-party package import-time-explodes
When a new package added under dependencies or peerDependencies crashes vitest at module-load with Cannot read properties of undefined (...) or ... is not a function, the symptom is a CJS/ESM interop mismatch — usually a CJS shim where a named export resolves undefined (sonner, lucide-react), or a package whose dist re-imports its own subpath exports (@modelcontextprotocol/server). Steps:
- Add
src/__tests__/__mocks__/<package-name>.tsexporting the minimal shape that the production code uses at module load (typically inert no-op functions, default Proxy for icon libraries, plain objects for runtime singletons). - Wire the alias in
packages/agents/vitest.config.tsunderresolve.alias. Place subpath aliases (@cinatra/foo/bar) BEFORE the bare alias (@cinatra/foo) so vite's prefix matcher prefers the more specific match. - Add a comment explaining the failure mode.
- Confirm: stubs are test-only — they MUST NOT leak into the production runtime. Never import from
src/__tests__/__mocks__/insrc/**non-test code.
Host-app config consistency
The repo-root vitest.config.ts (host app) currently runs on the default threads pool. If new cross-file mock-leak failures surface in the host-app suite, mirror the package config (pool: "forks").
Project Scoping integration
agent_runs.project_id text NULL has partial indexes (project_id, created_at DESC) and (project_id, status, created_at DESC). ActorRoleHints.projectGrants is part of the role-hint shape, and ActorContext.projectGrants is part of the resolved kernel context.
- Run-start propagation —
createAgentRunacceptsprojectId?: string|nulland writesagent_runs.project_idat INSERT. The BullMQ run worker (runAgentBuilderExecutionJob) readsrun.projectIdand wraps the inner execution body inmcpRequestContextStorage.run({...prev, projectContext: {projectId}}, ...)— frame is ALWAYS set (even when NULL) to defend against stale BullMQ-pool frames. - A2A carrier round-trip —
packages/agents/src/mcp/registry.tsforwardsa2a.projectGrantsinto the actor envelope;buildActorContextFromPrimitivereads carrier-forwarded grants gated onactor.actorType === "a2a"(security: never reads arbitrary primitive input). - Move —
agent_run_updateacceptsproject_idchange with active-run protection: movable setqueued/completed/failed/stopped; rejectrunning/pending_approval/pending_input/armed/pending_trigger/waiting_trigger. Newagent_run_move_with_outputsprimitive moves run + objects linked viaobjects.created_by_run_idin one audited tx; cross-tenant rejected. - Background-job actor snapshot —
ActorContext(includingprojectGrants) is serialized onto BullMQ jobs at enqueue and rehydrated at execution. Mid-flight access revocations are NOT seen by in-flight jobs (point-in-time snapshot accepted behavior).