diff --git a/README.md b/README.md index 11b29771c..df540a681 100644 --- a/README.md +++ b/README.md @@ -71,8 +71,16 @@ during `postinstall`. No additional build step is required for typical consumers ## Quickstart Pass your LangChain-style tools (`{ name, invoke }`) to `initAssembly` under -`langchain.tools`. Each tool is wrapped **in place** so every `invoke()` is checked -against gateway policy before it runs. +`langchain.tools`, along with the `gatewayClient` that decides them. Tools are +wrapped **in place**: the wrapper asks that client for a decision before running the +tool body, so a call the client denies is **denied before execution** — `invoke()` +throws `PolicyViolationError` and the body does not run. + +The `gatewayClient` is what makes that decision real. Wrap tools without one and +`initAssembly` throws a `ConfigurationError` under its default fail-closed posture, +rather than route tool checks through the allow-all no-op client it would otherwise +use. That refusal is a startup configuration check — it is not a policy decision about +any tool, and nothing is inspected when it fires. The snippets below take the LangChain adapter path, which needs `@langchain/core`. It is an **optional** peer dependency, so `pnpm add @agent-assembly/sdk` does not @@ -87,7 +95,24 @@ npm install @agent-assembly/sdk @langchain/core ### ESM (`import`) ```ts -import { initAssembly } from "@agent-assembly/sdk"; +import { initAssembly, type GatewayClient } from "@agent-assembly/sdk"; + +// The wrapper calls this before it runs a wrapped tool body. This one is a +// local allow-list so the snippet runs offline; point `check` at a gateway +// you run to source decisions from there instead. +const policyClient: GatewayClient = { + mode: "sdk-only", + start: async () => undefined, + close: async () => undefined, + check: async (request) => + request.toolName === "search_web" + ? { denied: false } + : { denied: true, reason: "not on the allow-list" }, + waitForApproval: async () => ({ denied: false }), + record: async () => undefined, + recordResult: async () => undefined, + scanPrompts: async () => undefined +}; const searchWeb = { name: "search_web", @@ -97,10 +122,11 @@ const searchWeb = { const ctx = await initAssembly({ gatewayUrl: "http://localhost:7391", agentId: "demo", + gatewayClient: policyClient, langchain: { tools: { searchWeb } } }); -await searchWeb.invoke({ q: "agent assembly" }); // governed; throws on policy deny +console.log(await searchWeb.invoke({ q: "agent assembly" })); await ctx.shutdown(); ``` @@ -109,27 +135,54 @@ await ctx.shutdown(); ```js const { initAssembly } = require("@agent-assembly/sdk"); +// The wrapper calls this before it runs a wrapped tool body. This one is a +// local allow-list so the snippet runs offline; point `check` at a gateway +// you run to source decisions from there instead. +const policyClient = { + mode: "sdk-only", + start: async () => undefined, + close: async () => undefined, + check: async (request) => + request.toolName === "search_web" + ? { denied: false } + : { denied: true, reason: "not on the allow-list" }, + waitForApproval: async () => ({ denied: false }), + record: async () => undefined, + recordResult: async () => undefined, + scanPrompts: async () => undefined +}; + const searchWeb = { name: "search_web", invoke: async (input) => `results for ${input.q}` }; -const ctx = await initAssembly({ - gatewayUrl: "http://localhost:7391", - agentId: "demo", - langchain: { tools: { searchWeb } } -}); - -await searchWeb.invoke({ q: "agent assembly" }); -await ctx.shutdown(); +// CommonJS has no top-level await, so the init/shutdown sequence runs +// inside an async function. +async function main() { + const ctx = await initAssembly({ + gatewayUrl: "http://localhost:7391", + agentId: "demo", + gatewayClient: policyClient, + langchain: { tools: { searchWeb } } + }); + + console.log(await searchWeb.invoke({ q: "agent assembly" })); + await ctx.shutdown(); +} + +main(); ``` Both entrypoints resolve to the same governance pipeline; the package's `exports` field selects ESM or CJS automatically based on how the consumer imports it. `initAssembly()` registers the LangChain callback handler and auto-wraps the configured -tools, so each is checked against gateway policy before invocation. For more frameworks -and the lower-level `withAssembly()` wrapper, see the **Examples** guide on the +tools, so a wrapped `invoke()` reaches your `gatewayClient` for a decision before the +tool body runs. What that decision is worth is whatever the client backs it with: the +allow-list above answers locally, while a client that consults a gateway you run carries +that gateway's verdict. For more frameworks and the lower-level `withAssembly()` +wrapper, see the **Examples** guide on the [documentation site](https://docs.agent-assembly.com/node-sdk/). ## Supported Node.js versions @@ -153,8 +206,8 @@ binding requires Node 18.18 or newer. ## Framework compatibility -`initAssembly()` auto-detects and governs five optional framework integrations -(LangChain.js, LangGraph.js, Vercel AI SDK, Mastra, OpenAI Agents). The full table — +`initAssembly()` auto-detects and installs governance hooks for five optional framework +integrations (LangChain.js, LangGraph.js, Vercel AI SDK, Mastra, OpenAI Agents). The full table — each framework's optional peer dependency, supported version range, and current status (including the [known Vercel AI SDK caveat](https://lightning-dust-mite.atlassian.net/browse/AAASM-3532)) — is the **authoritative** reference and lives on the docs site: @@ -174,8 +227,10 @@ the runtime over one of two transports: - a **native in-process** binding built with napi-rs. `initAssembly()` is the primary entrypoint. It resolves the gateway, registers the agent, -and installs governance hooks for whichever supported framework it detects, so every tool -call is checked against policy before it runs. +and installs governance hooks for whichever supported framework it detects. What a hook +can do to a call depends on the gateway client deciding it — see +[How LangChain tools are blocked](#how-langchain-tools-are-blocked) for which layer +blocks and which only observes. ## What the package exports @@ -346,8 +401,8 @@ and is re-published on every push to `main` via the `publish-docs.yml` workflow. ## Related projects -`@agent-assembly/sdk` is one client of the Agent Assembly platform. The governance -decisions it enforces are made by the core Rust runtime; the protocol it speaks is shared +`@agent-assembly/sdk` is one client of the Agent Assembly platform. The authoritative +governance decisions are made by the core Rust runtime; the protocol it speaks is shared across all SDKs. | Project | What it is | diff --git a/docs/01-introduction/index.md b/docs/01-introduction/index.md index 1ff5636a0..a2ff8599a 100644 --- a/docs/01-introduction/index.md +++ b/docs/01-introduction/index.md @@ -6,16 +6,24 @@ sidebar_position: 1 # Introduction **In plain terms:** AI agents take actions on their own — searching the web, calling -APIs, reading files. This SDK puts a checkpoint in front of those actions so an agent -can only do what your rules allow, and so there's a record of everything it did. You add -it to an agent built in Node.js with a few lines of code; you don't have to rewrite the -agent. +APIs, reading files. This SDK puts a checkpoint in front of those actions, so a tool you +wrap is decided by a policy client before its body runs. How much that checkpoint is +worth depends on how you configure it: the client the SDK falls back to allows +everything and keeps no record, which the two sections below spell out. You add it to an +agent built in Node.js with a few lines of code; you don't have to rewrite the agent. **`@agent-assembly/sdk`** is the TypeScript and Node.js SDK for [Agent Assembly](https://github.com/ai-agent-assembly). It lets you put a -governance layer in front of the AI agents you build in Node — so every tool an -agent calls is checked against policy *before* it runs, and every governance-relevant -action is emitted as an audit event. +governance layer in front of the AI agents you build in Node — so a tool you wrap +reaches the gateway client you configure for a decision *before* its body runs, and +governance-relevant actions are emitted as audit events. + +What that buys depends on the client. Tools decided by a client that can answer +authoritatively are **denied before execution** when it denies them. Pass explicit +`langchain.tools` without such a client and `initAssembly` refuses to start, rather +than route their checks through the allow-all no-op client. For a framework it +auto-detects instead, there is no such refusal — it warns and proceeds, so that +installing a dependency does not break a zero-config startup. Whether those events are *retained* depends on which gateway client you use. Both clients this SDK ships discard hook-layer audit events, so on the default path @@ -40,10 +48,10 @@ In practice the SDK is two things working together: your policies and renders allow / deny / approval decisions. The SDK can even auto-start a local gateway for you so there is nothing to stand up by hand. -You write your agent the way you normally would. The SDK wraps each tool so the -gateway sees the call first: if policy **allows** it, the tool runs; if it **denies** -it, the call throws instead of executing; if it needs a human, the call waits for an -approval decision. +You write your agent the way you normally would. The SDK wraps the tools you hand it +so the gateway client you configured decides the call first: if it **allows**, the +tool runs; if it **denies**, the call throws instead of executing; if it needs a +human, the call waits for an approval decision. ## Who this is for diff --git a/docs/03-core-concepts/index.md b/docs/03-core-concepts/index.md index 8f035fad9..0edacc33e 100644 --- a/docs/03-core-concepts/index.md +++ b/docs/03-core-concepts/index.md @@ -20,7 +20,9 @@ flowchart LR ``` You call `initAssembly(...)` once. From then on, the tools you handed it are wrapped -so the gateway evaluates each call before it executes. +so the gateway client you configured decides a call before it executes. The path drawn +above is the one a client that reaches the gateway takes; the client this SDK falls back +to when you supply none answers in-process and allows everything. The gateway, the policy engine, and the audit trail live in the core runtime. For the platform-level picture — how the gateway renders decisions and how this SDK relates to diff --git a/docs/04-guides/index.md b/docs/04-guides/index.md index db8eb1695..323503496 100644 --- a/docs/04-guides/index.md +++ b/docs/04-guides/index.md @@ -32,11 +32,30 @@ SDK's own test suite. ## LangChain (validated) Install `@langchain/core` (a peer dependency). Pass your tools to `initAssembly` under -`langchain.tools`; each tool is wrapped **in place** so every `invoke()` is checked -against gateway policy before it runs. The callback handler is registered automatically. +`langchain.tools`, along with the `gatewayClient` that decides them; tools are wrapped +**in place**, so a call that client denies is **denied before execution** — the wrapper +throws `PolicyViolationError` and the tool body does not run. The callback handler is +registered automatically. Wrapping tools without such a client is refused at startup — +see the note below the snippet. ```ts -import { initAssembly } from "@agent-assembly/sdk"; +import { initAssembly, type GatewayClient } from "@agent-assembly/sdk"; + +// The wrapper calls this before it runs a wrapped tool body. This one is a local +// allow-list so the snippet runs offline; point `check` at a gateway you run instead. +const policyClient: GatewayClient = { + mode: "sdk-only", + start: async () => undefined, + close: async () => undefined, + check: async (request) => + request.toolName === "search_web" + ? { denied: false } + : { denied: true, reason: "not on the allow-list" }, + waitForApproval: async () => ({ denied: false }), + record: async () => undefined, + recordResult: async () => undefined, + scanPrompts: async () => undefined +}; // A LangChain-style tool is any object with { name, invoke }. const searchWeb = { @@ -47,20 +66,23 @@ const searchWeb = { }; const ctx = await initAssembly({ + gatewayUrl: "http://localhost:7391", agentId: "demo", + gatewayClient: policyClient, langchain: { tools: { searchWeb }, approvalTimeoutMs: 30_000 // optional; how long to wait on a "pending" decision } }); -// Governed: if policy denies the call, invoke() rejects with a PolicyViolationError. -await searchWeb.invoke({ q: "agent assembly" }); +// policyClient allows search_web, so this runs and returns. A tool it denies +// would reject with PolicyViolationError instead. +console.log(await searchWeb.invoke({ q: "agent assembly" })); await ctx.shutdown(); ``` -When the gateway returns a **deny**, the wrapped call throws `PolicyViolationError`. When +When the client returns a **deny**, the wrapped call throws `PolicyViolationError`. When it returns **pending**, the call waits up to `approvalTimeoutMs` for a decision and then either proceeds or throws. @@ -179,14 +201,14 @@ For the full list of configuration fields used above, see ## Handling allow / deny decisions and errors When you wrap a tool — whether through `initAssembly`'s `langchain.tools` or directly -with `withAssembly` — the gateway is consulted on every call. The outcome shows up as -ordinary async control flow: +with `withAssembly` — the wrapper asks your `gatewayClient` for a decision before it +runs the tool body. The outcome shows up as ordinary async control flow: - **Allow.** The wrapped call runs the real tool and returns its result. Nothing extra to handle. - **Deny.** The wrapped call **rejects** with a `PolicyViolationError`. The tool body - never runs. The error message carries the tool name and the gateway's stated reason. -- **Pending → resolved.** If the gateway needs a human, the call waits up to + never runs. The error message carries the tool name and the reason the client gave. +- **Pending → resolved.** If the decision needs a human, the call waits up to `approvalTimeoutMs` for a decision and then either proceeds (approved) or rejects (denied / timed out). @@ -196,8 +218,11 @@ Because these surface as rejected promises, you handle them with a normal ```ts import { initAssembly } from "@agent-assembly/sdk"; +// policyClient and searchWeb are the ones defined in the LangChain section above. const ctx = await initAssembly({ + gatewayUrl: "http://localhost:7391", agentId: "demo", + gatewayClient: policyClient, langchain: { tools: { searchWeb }, approvalTimeoutMs: 30_000 // how long to wait on a "pending" decision diff --git a/docs/07-compatibility-versioning/compatibility.md b/docs/07-compatibility-versioning/compatibility.md index 272fb0ab8..5aa8ec7ae 100644 --- a/docs/07-compatibility-versioning/compatibility.md +++ b/docs/07-compatibility-versioning/compatibility.md @@ -29,7 +29,8 @@ requires a Rust toolchain. See [Troubleshooting](../08-troubleshooting/index.md) ## Frameworks -`initAssembly()` auto-detects and governs the agent frameworks below. Each is an +`initAssembly()` auto-detects and installs governance hooks for the agent frameworks +below. Each is an **optional** peer dependency — the SDK works without any of them installed, and only hooks into the ones it finds at runtime. The version floors are the major lines the governance hooks are built against and verified in the cross-repo live smokes; newer diff --git a/docs/08-troubleshooting/index.md b/docs/08-troubleshooting/index.md index 2ed174df2..1b47fabd3 100644 --- a/docs/08-troubleshooting/index.md +++ b/docs/08-troubleshooting/index.md @@ -63,8 +63,9 @@ agent under the wrong governance posture. See [Configuration](../05-configuratio ## `PolicyViolationError` at tool call time -This is expected behavior, not a bug: the gateway **denied** the tool call (or an approval -request timed out). The message includes the tool name and the gateway's reason. To run an +This is expected behavior, not a bug: the gateway client deciding the call **denied** it +(or an approval request timed out). The message includes the tool name and the reason that +client gave. To run an agent without blocking while you tune policy, register it with `enforcementMode: "observe"` — actions proceed and would-be violations are recorded as shadow audit events. diff --git a/tests/quickstart-claim-bindings.test.ts b/tests/quickstart-claim-bindings.test.ts index 88d42fa7e..f123664ff 100644 --- a/tests/quickstart-claim-bindings.test.ts +++ b/tests/quickstart-claim-bindings.test.ts @@ -116,9 +116,14 @@ const BINDINGS: readonly ClaimBinding[] = [ id: "sdk-enforces-by-talking-to-a-gateway", quote: "The SDK enforces policy by talking to an Agent Assembly **gateway**.", unprovenReason: - "AAASM-5663: both README entrypoints were executed and neither runs as written, so no " + - "control covers a reader actually reaching a gateway. The control that exists asserts " + - "the documented config REFUSES to init; it does not prove this sentence." + "AAASM-5758: no control covers a reader actually reaching a gateway. Every control " + + "that exists decides tool calls through a caller-supplied gatewayClient answering " + + "in-process, which is what the documented snippets do — so they prove a wrapped " + + "call is decided before its body runs, not that the deciding party is a gateway. " + + "Closing this needs a CI job that runs each documented quick-start from a clean " + + "environment against published artifacts only, which AAASM-5758 owns. This is a " + + "forward pointer to the work that would prove the sentence, not a citation for " + + "where it was last examined." }, { id: "auto-start-is-opt-in",