@visiq/harness
v0.2.25
Published
VisIQ agent-governance harness for Node.js — action governance, retrieval governance, and audit-trail reporting, added to your existing agent framework by one visiq() call.
Maintainers
Readme
@visiq/harness
Governance for AI agents — one function, injected into your agentic framework.
@visiq/harness wraps the tools and executors of your agent framework so every
tool call and retrieval is evaluated against your VisIQ governance rules in
process, before it runs. Decisions are made by the same compiled governance
core (@visiq/core-wasm) that powers the VisIQ platform — so what you enforce
locally is exactly what the control plane authored.
- Fail-open by default, loudly — strict deny is opt-in. A policy deny
always blocks. But if VisIQ itself can't reach a decision (core unavailable,
network error, no bundle), the call proceeds ungoverned with a loud
fail-open report rather than stalling your agent — a VisIQ fault must never
become your outage. Set
VISIQ_FAIL_MODE=closed(orfailMode: 'closed') to deny in that case instead. - One call to adopt —
visiq(target)wraps a framework executor or an individual tool. No rewrite of your agent. - Human-in-the-loop — an
approval_requireddecision blocks the tool and waits for a human to approve/deny, bounded by a timeout. When the timeout elapses the call is blocked. - Retrieval redaction — masks sensitive fields in retrieved content according to policy.
Install
npm install @visiq/harness@visiq/core-wasm (the compiled decision core) is a required dependency and
is installed automatically. Node.js ≥ 20 only (the core is loaded as native
Node WASM — not a browser build).
Quickstart
import { visiq } from "@visiq/harness";
import { AgentExecutor } from "langchain/agents";
// Wrap your framework's executor — every governed tool call now flows
// through VisIQ before it executes.
const executor = visiq(new AgentExecutor({ agent, tools }));
await executor.invoke({ input: "..." });Wrap an individual tool with an explicit, rule-friendly agent identity:
const search = visiq(new SearchTool(), { agentId: "research-agent" });Configuration
Options may be passed to visiq(target, options) or supplied via environment
variables (option takes precedence, then the env var):
| Option | Env var | Purpose |
|---|---|---|
| apiKey | VISIQ_API_KEY | Backend API key. |
| endpoint | VISIQ_ENDPOINT | Backend endpoint URL. Optional — defaults to the managed SaaS host https://api.visiqlabs.com; set it explicitly only for onprem / self-hosted deployments. |
| agentId | VISIQ_AGENT_ID | Stable agent identity. If unset it is derived (package name → hostname) and auto-provisioned in monitor mode. |
| hitlTimeoutMs | VISIQ_HITL_TIMEOUT_MS | Max wait (ms) for a human to resolve an approval_required decision before failing closed. Default 120000. |
| timeoutMs | VISIQ_TIMEOUT_MS | Per-evaluate network timeout before the action pre-gate fails closed on a stuck backend. Default 5s. Raise it for high-latency paths. |
| onInstrumentFailure | VISIQ_ON_INSTRUMENT_FAILURE | 'warn' | 'throw' — what to do when a framework is detected but ZERO tools are instrumented. Unset is context-sensitive: an agent that ADVERTISES tools but instruments none fails closed; a genuinely tool-less agent only warns. |
| failMode | VISIQ_FAIL_MODE | 'open' (default) | 'closed' — posture for VisIQ's own failures. Policy outcomes are unaffected. |
| sessionId | (none — deliberately) | Explicit session key for sequence-aware rules (input.session.*). See Sessions. |
| identity | (none — deliberately) | Per-call identity attestation for identity-binding rules. See Identity. |
Two options have no environment variable, on purpose. sessionId and
identity are per-conversation and per-call facts; a process-wide env var would
apply one value to every request an agent-per-request server handles — fusing
unrelated users into one session, or attesting one user's principal for
everybody. Both are strictly worse than leaving them unset.
Sessions and sequence-aware rules
A rule can condition on what the session has already done
(input.session.event_count, input.session.action_counts.*,
input.session.retrieved_data_categories, …). That needs a session key, and
the key comes from your application.
Resolution order, highest first:
sessionIdpassed tovisiq(target, { sessionId })- LangChain/LangGraph only:
configurable.thread_idon the call config - LangChain/LangGraph only:
configurable.session_id - a fresh id per top-level run (so a caller who threads nothing is unaffected)
// LangGraph / LangChain — nothing to configure: thread your conversation id and
// the two turns share a session.
await graph.invoke(input, { configurable: { thread_id: conversationId } });
// Every other framework — name the conversation on the wrap.
const agent = visiq(new Agent({ model, tools }), { sessionId: conversationId });Reach, per framework
| framework | native conversation key | sessionId option | spans runs | concurrent runs isolated |
|---|---|---|---|---|
| langchain | yes | yes | yes | yes |
| vercel_ai | no | yes | yes | yes |
| mastra | no | yes | yes | yes |
| voltagent | no | yes | yes | yes |
| llamaindex | no | yes | yes | yes |
| openai_agents | no | yes | yes | no |
| semantic_kernel | no | yes | yes | no |
| bare_tool | no | yes | yes | no |
- native conversation key — the harness reads a durable id from the framework's own config, so you configure nothing.
- concurrent runs isolated — two simultaneous runs of ONE wrapped object in
one process do not share a key. Where this is
no, the framework exposes no per-run context the harness can hang a scope on. Forsemantic_kernelandbare_tool, a host key you supply is by definition shared by everything that uses it — pass a per-conversation value, not a process-wide one.
⚠️ Sessions accumulate IN-PROCESS
The trajectory is folded into an in-memory map on the harness's own runtime:
512 live sessions (LRU), 30-minute idle expiry, per process. Two turns that
land on two replicas therefore see two fresh, empty sessions, and a
sequence-aware rule can be correct, deployed and silently inert. If you run more
than one replica, pin conversation affinity at your load balancer, or accept that
input.session.* conditions only see the turns one replica handled.
Identity attestation
Some rules bind a privileged action to a principal the session has proven:
deny if not input.normalized.write.subject_id
in input.session.identity.attested_subjectsSupply the principal per call:
const agent = visiq(new Agent({ model, tools }), {
sessionId: conversationId,
identity: () => {
const req = requestContext.getStore(); // YOUR request-scoped context
return req && { attested: [req.userId], subjectId: req.targetUserId };
},
});| field | meaning |
|---|---|
| attested | principals your application can PROVE for this call — an IdP/session assertion, your request context, a verification tool's result. The only field that writes attested_subjects. |
| claimed | principals the conversation merely asserted. Grants nothing; exists so a rule can see the divergence. |
| subjectId | the human this action acts UPON. Becomes normalized.write.subject_id. |
⚠️ VisIQ cannot verify this. The harness runs inside your process, so
attested is an assertion by your application — recorded, folded and
rule-matchable, never independently checked. Source it from a channel the
untrusted party cannot author. A principal the model read out of the
conversation belongs in claimed; if you cannot tell the two apart, pass
neither.
Two behaviours worth knowing:
- The fold runs after the decision, so a principal attested on turn N is visible from turn N+1. That is the intended pattern: a verification step registers the principal, then the privileged action binds against it.
- With no
identitysupplied, a rule of the shape above does not fire at all — its left operand is absent, so the condition is undefined and a lower-priority rule decides. It is not a safe default; it is a no-op. SupplysubjectIdto arm the rule.
Values are bounded to the control plane's own limits (≤32 principals of ≤255 characters). Oversized input is truncated rather than rejected — a rejected event would fail the whole evaluation, which by default proceeds ungoverned.
Multi-agent delegation
For multi-agent delegation flows, mint the env a child process needs so its authority is a scoped delegation of the parent's:
const childEnv = await visiq.delegate("summarizer-agent");
spawn("node", ["summarizer.js"], { env: { ...process.env, ...childEnv } });Behavior contract
- Deny is terminal — a denied tool call throws before the tool runs; the underlying tool is never invoked.
- Approval blocks — an
approval_requireddecision suspends the call until a human resolves it, or the HITL timeout elapses (then the call is blocked). - Harness errors fail OPEN by default, but never silently — an evaluation
error proceeds ungoverned with a loud fail-open report on stderr and a
failOpenflag on the decision, so it is always visible in your telemetry. SetVISIQ_FAIL_MODE=closedto deny instead. This governs VisIQ's own failures only — it never softens a policy outcome.
License
MIT © VisIQ Labs. See LICENSE.
