nexus-ai-pro 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/API_STABILITY.md +39 -0
  2. package/CHANGELOG.md +142 -1
  3. package/README.md +10 -5
  4. package/dist/agent/capabilities.d.ts +39 -0
  5. package/dist/agent/capabilities.js +36 -0
  6. package/dist/agent/create-agent.d.ts +61 -11
  7. package/dist/agent/create-agent.js +136 -22
  8. package/dist/agent/index.d.ts +3 -2
  9. package/dist/agent/index.js +1 -1
  10. package/dist/agent/middleware/approval.d.ts +26 -0
  11. package/dist/agent/middleware/approval.js +23 -0
  12. package/dist/agent/{middleware.d.ts → middleware/basic.d.ts} +5 -4
  13. package/dist/agent/{middleware.js → middleware/basic.js} +3 -5
  14. package/dist/agent/middleware/context-editor.d.ts +52 -0
  15. package/dist/agent/middleware/context-editor.js +66 -0
  16. package/dist/agent/middleware/filesystem.d.ts +44 -0
  17. package/dist/agent/middleware/filesystem.js +83 -0
  18. package/dist/agent/middleware/index.d.ts +19 -0
  19. package/dist/agent/middleware/index.js +9 -0
  20. package/dist/agent/middleware/limits.d.ts +42 -0
  21. package/dist/agent/middleware/limits.js +52 -0
  22. package/dist/agent/middleware/models.d.ts +48 -0
  23. package/dist/agent/middleware/models.js +44 -0
  24. package/dist/agent/middleware/pii.d.ts +67 -0
  25. package/dist/agent/middleware/pii.js +173 -0
  26. package/dist/agent/middleware/retry.d.ts +66 -0
  27. package/dist/agent/middleware/retry.js +59 -0
  28. package/dist/agent/middleware/shared.d.ts +41 -0
  29. package/dist/agent/middleware/shared.js +73 -0
  30. package/dist/agent/middleware/tool-selector.d.ts +64 -0
  31. package/dist/agent/middleware/tool-selector.js +106 -0
  32. package/dist/agent/permissions.d.ts +113 -0
  33. package/dist/agent/permissions.js +180 -0
  34. package/dist/agent/sandbox.d.ts +151 -0
  35. package/dist/agent/sandbox.js +257 -0
  36. package/dist/agent/tool.d.ts +8 -3
  37. package/dist/agent/tool.js +14 -3
  38. package/dist/deep-agent/index.d.ts +109 -0
  39. package/dist/deep-agent/index.js +216 -0
  40. package/dist/graph/functional.d.ts +9 -0
  41. package/dist/graph/functional.js +20 -3
  42. package/dist/graph/graph.js +25 -4
  43. package/dist/graph/lint.d.ts +4 -1
  44. package/dist/graph/lint.js +35 -0
  45. package/dist/postgres/fulltext.d.ts +67 -0
  46. package/dist/postgres/fulltext.js +91 -0
  47. package/dist/rag/elasticsearch.d.ts +71 -0
  48. package/dist/rag/elasticsearch.js +130 -0
  49. package/dist/rag/pipeline.d.ts +140 -0
  50. package/dist/rag/pipeline.js +187 -0
  51. package/dist/rag/rerankers.d.ts +92 -0
  52. package/dist/rag/rerankers.js +97 -0
  53. package/dist/rag/retrievers.d.ts +12 -1
  54. package/dist/server/assistant.d.ts +5 -1
  55. package/dist/server/assistant.js +4 -2
  56. package/dist/server/auth.d.ts +250 -0
  57. package/dist/server/auth.js +392 -0
  58. package/dist/store/memory.d.ts +10 -0
  59. package/dist/store/memory.js +10 -0
  60. package/dist/types/graph.d.ts +38 -0
  61. package/dist/types/messages.d.ts +36 -2
  62. package/dist/types/principal.d.ts +21 -0
  63. package/dist/types/principal.js +1 -0
  64. package/dist/types/server.d.ts +2 -9
  65. package/dist/utils/tenant.d.ts +11 -0
  66. package/dist/utils/tenant.js +14 -0
  67. package/dist/version.d.ts +1 -1
  68. package/dist/version.js +1 -1
  69. package/dist-cjs/agent/capabilities.d.ts +39 -0
  70. package/dist-cjs/agent/capabilities.js +41 -0
  71. package/dist-cjs/agent/create-agent.d.ts +61 -11
  72. package/dist-cjs/agent/create-agent.js +136 -22
  73. package/dist-cjs/agent/index.d.ts +3 -2
  74. package/dist-cjs/agent/index.js +4 -4
  75. package/dist-cjs/agent/middleware/approval.d.ts +26 -0
  76. package/dist-cjs/agent/middleware/approval.js +26 -0
  77. package/dist-cjs/agent/{middleware.d.ts → middleware/basic.d.ts} +5 -4
  78. package/dist-cjs/agent/{middleware.js → middleware/basic.js} +3 -5
  79. package/dist-cjs/agent/middleware/context-editor.d.ts +52 -0
  80. package/dist-cjs/agent/middleware/context-editor.js +70 -0
  81. package/dist-cjs/agent/middleware/filesystem.d.ts +44 -0
  82. package/dist-cjs/agent/middleware/filesystem.js +86 -0
  83. package/dist-cjs/agent/middleware/index.d.ts +19 -0
  84. package/dist-cjs/agent/middleware/index.js +28 -0
  85. package/dist-cjs/agent/middleware/limits.d.ts +42 -0
  86. package/dist-cjs/agent/middleware/limits.js +57 -0
  87. package/dist-cjs/agent/middleware/models.d.ts +48 -0
  88. package/dist-cjs/agent/middleware/models.js +48 -0
  89. package/dist-cjs/agent/middleware/pii.d.ts +67 -0
  90. package/dist-cjs/agent/middleware/pii.js +178 -0
  91. package/dist-cjs/agent/middleware/retry.d.ts +66 -0
  92. package/dist-cjs/agent/middleware/retry.js +63 -0
  93. package/dist-cjs/agent/middleware/shared.d.ts +41 -0
  94. package/dist-cjs/agent/middleware/shared.js +83 -0
  95. package/dist-cjs/agent/middleware/tool-selector.d.ts +64 -0
  96. package/dist-cjs/agent/middleware/tool-selector.js +109 -0
  97. package/dist-cjs/agent/permissions.d.ts +113 -0
  98. package/dist-cjs/agent/permissions.js +189 -0
  99. package/dist-cjs/agent/sandbox.d.ts +151 -0
  100. package/dist-cjs/agent/sandbox.js +297 -0
  101. package/dist-cjs/agent/tool.d.ts +8 -3
  102. package/dist-cjs/agent/tool.js +14 -3
  103. package/dist-cjs/deep-agent/index.d.ts +109 -0
  104. package/dist-cjs/deep-agent/index.js +219 -0
  105. package/dist-cjs/graph/functional.d.ts +9 -0
  106. package/dist-cjs/graph/functional.js +20 -3
  107. package/dist-cjs/graph/graph.js +25 -4
  108. package/dist-cjs/graph/lint.d.ts +4 -1
  109. package/dist-cjs/graph/lint.js +35 -0
  110. package/dist-cjs/postgres/fulltext.d.ts +67 -0
  111. package/dist-cjs/postgres/fulltext.js +129 -0
  112. package/dist-cjs/rag/elasticsearch.d.ts +71 -0
  113. package/dist-cjs/rag/elasticsearch.js +135 -0
  114. package/dist-cjs/rag/pipeline.d.ts +140 -0
  115. package/dist-cjs/rag/pipeline.js +191 -0
  116. package/dist-cjs/rag/rerankers.d.ts +92 -0
  117. package/dist-cjs/rag/rerankers.js +107 -0
  118. package/dist-cjs/rag/retrievers.d.ts +12 -1
  119. package/dist-cjs/server/assistant.d.ts +5 -1
  120. package/dist-cjs/server/assistant.js +4 -2
  121. package/dist-cjs/server/auth.d.ts +250 -0
  122. package/dist-cjs/server/auth.js +402 -0
  123. package/dist-cjs/store/memory.d.ts +10 -0
  124. package/dist-cjs/store/memory.js +10 -0
  125. package/dist-cjs/types/graph.d.ts +38 -0
  126. package/dist-cjs/types/messages.d.ts +36 -2
  127. package/dist-cjs/types/principal.d.ts +21 -0
  128. package/dist-cjs/types/principal.js +2 -0
  129. package/dist-cjs/types/server.d.ts +2 -9
  130. package/dist-cjs/utils/tenant.d.ts +11 -0
  131. package/dist-cjs/utils/tenant.js +15 -0
  132. package/dist-cjs/version.d.ts +1 -1
  133. package/dist-cjs/version.js +1 -1
  134. package/llms.txt +4 -3
  135. package/package.json +118 -1
package/API_STABILITY.md CHANGED
@@ -41,6 +41,45 @@ Both are covered by this policy:
41
41
  Deep imports into `dist`, `dist-cjs`, or `src` are not supported. Import only from `nexus-ai-pro` or
42
42
  one of its explicit subpaths.
43
43
 
44
+ ## 2.3 stage (2.3.0)
45
+
46
+ Everything 2.3 adds is stable from the start and additive: no default changes meaning.
47
+
48
+ - `ToolDefinition.capabilities` and the capability grammar; `permissionPolicy()`, `PermissionPolicy`,
49
+ `PermissionRules`, `PermissionRequest`, `PermissionVerdict`, `PermissionDecision`,
50
+ `PermissionPolicyLike`, `parseCapability()`, `capabilitiesOf()`, `isSensitiveCapability()`, and
51
+ `normalizePath()` (`nexus-ai-pro/agent/permissions`); `CreateAgentOptions.permissions`;
52
+ - the sandbox contract: `Sandbox`, its option, result, and isolation types, `sandboxTools()`,
53
+ `runSandboxConformance()` and its report types, `SandboxPathError` (`nexus-ai-pro/agent/sandbox`).
54
+ `processSandbox()` is a development reference and may change; conformance checks may be added;
55
+ - `NodeOptions.effects` and `interrupts`, `CompileOptions.tools`, `GraphToolDescription`, and the lint
56
+ codes `SENSITIVE_TOOL_WITHOUT_APPROVAL`, `UNDECLARED_TOOL_CAPABILITIES`, and
57
+ `SIDE_EFFECT_BEFORE_INTERRUPT`;
58
+ - `nexus-ai-pro/agent/middleware` and every middleware and option type on it;
59
+ `AgentMiddleware.wrapModelCall`, `AgentMiddlewareContext`, `AgentModelCallContext`, the third
60
+ argument of `wrapToolCall`, `next(call)`, and `AgentStopReason` `'stopped'`. The PII patterns
61
+ may be tightened in a minor release to cut false positives, and new `PiiKind` members may be
62
+ added;
63
+ - `nexus-ai-pro/server/auth`: `jwtAuth()`, `apiKeyAuth()`, `trustedProxyAuth()`, `anyAuth()`,
64
+ `createJwtVerifier()`, `hashApiKey()`, `JwtError` and its codes, and every option type. New
65
+ `JwtAlgorithm` and `JwtErrorCode` members may be added;
66
+ - `Principal.roles`, `method`, and `claims`; `GraphRunOptions.principal`, `NodeContext.principal`,
67
+ `WorkflowRunOptions.principal`, and `WorkflowContext.principal`; `ToolContext` and the second
68
+ argument of `ToolDefinition.execute`; `AgentMiddlewareContext.principal` and
69
+ `PermissionRequest.principal`. A checkpoint's `metadata.userId` is recorded when a run has a
70
+ principal with a subject;
71
+ - `SparseRetriever`; `PostgresKeywordIndex` and `keywordIndexMigrations()`
72
+ (`nexus-ai-pro/postgres/fulltext`); `ElasticsearchKeywordIndex` and `ElasticsearchError`
73
+ (`nexus-ai-pro/rag/elasticsearch`); and every reranker and option type on
74
+ `nexus-ai-pro/rag/rerankers`. The default endpoints of hosted rerankers follow their providers'
75
+ current API versions;
76
+ - `nexus-ai-pro/rag/pipeline`: `createIngestionPipeline()`, `ingestionExecutor()`, and their types.
77
+ The manifest's layout under `['nexus', 'ingestion', <name>]` is stable: a later release reads a
78
+ manifest an earlier one wrote;
79
+ - `nexus-ai-pro/deep-agent`: `createDeepAgent()`, `DeepAgentOptions`, `DeepSubagent`, and
80
+ `DeepSkill`, and the names and arguments of the tools it gives the model (`write_todos`,
81
+ `delegate`, `load_skill`, `edit_file`). The wording of its system prompt may change in any release.
82
+
44
83
  ## 2.2 stage (2.2.0)
45
84
 
46
85
  Everything 2.2 adds is stable from the start and additive: no default changes meaning.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,146 @@ Notable changes to this project are recorded here. The format follows [Keep a Ch
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.3.0] - 2026-10-06
8
+
9
+ Agents that act safely, on production retrieval.
10
+
11
+ ### Added
12
+
13
+ - **Capabilities and permission policies.** A tool declares what each call does —
14
+ `filesystem:write:<path>`, `network:<host>`, `shell:<command>`, `code`, or an effect of the
15
+ application's own — as a list or a function of its arguments. `permissionPolicy()`, on the new
16
+ `nexus-ai-pro/agent/permissions`, grants filesystem globs, network hosts, and shell command
17
+ prefixes, and decides each call: allow, deny, or ask. Paths are normalized before any grant sees
18
+ them, and a command that chains, pipes, or substitutes is never granted by a prefix.
19
+ `createAgent({ permissions })` enforces it before a tool runs: a denied call never runs, an `ask`
20
+ interrupts as `interruptOn` does, and arguments an approver edits are decided again.
21
+ - **A sandbox contract**, on the new `nexus-ai-pro/agent/sandbox`: the `Sandbox` interface a
22
+ container, VM, or hosted interpreter fills; `sandboxTools()`, whose calls declare exactly the
23
+ command or path they use; `runSandboxConformance()`, which proves any sandbox against the
24
+ contract; and `processSandbox()`, a reference for development that is never a security boundary
25
+ and says so.
26
+ - **The linter reads capabilities.** `SENSITIVE_TOOL_WITHOUT_APPROVAL` reports a tool that writes, runs
27
+ commands or code, or reaches the network with neither approval nor a policy;
28
+ `UNDECLARED_TOOL_CAPABILITIES` a tool whose effects cannot be judged; and
29
+ `SIDE_EFFECT_BEFORE_INTERRUPT` a node that declares `effects` and `interrupts`. An agent's graph
30
+ describes its tools for it.
31
+ - **A middleware catalog**, on the new `nexus-ai-pro/agent/middleware`, each a separate import:
32
+ `modelRetry()`, `modelFallback()` (to another model or another client), `dynamicModel()`,
33
+ `toolRetry()` (which by default repeats only a call that cannot have changed anything),
34
+ `toolSelector()` (by rule, embedding similarity, or both), `contextEditor()` (clears old tool
35
+ results in batches, so a prompt cache keeps working), `piiMiddleware()` (validated card numbers
36
+ and IBANs; redact, mask, hash, or block), `humanApproval()` (per call, in band or out of band),
37
+ `modelCallLimit()` (per run and per thread, the thread count shared through the store), and
38
+ `filesystemContext()` (instruction files in, large results out to a file). The three existing
39
+ middleware are there too.
40
+ - **A wider middleware seam.** `wrapModelCall` wraps the model call itself, and may call `next()`
41
+ again to retry or fall back. Every hook receives an `AgentMiddlewareContext`: the state, the
42
+ store, the thread and tenant, the run's signal, and `interrupt()`. `context.stop()` ends a run
43
+ with an answer and the new `stopReason: 'stopped'`. A `wrapToolCall` can pass `next()` changed
44
+ arguments, which the permission policy decides again.
45
+ - **Server authentication**, on the new `nexus-ai-pro/server/auth`. It is built on Web Crypto, with no
46
+ dependency:
47
+ - `jwtAuth()` takes an identity provider's key set (cached, and refetched when a token names a new
48
+ key), a public key, or a shared secret, and checks issuer, audience, expiry, and age. It refuses
49
+ `alg: none` and key confusion, and requires an audience with a key set.
50
+ - `apiKeyAuth()` compares keys by their SHA-256 hash, from `hashApiKey()`.
51
+ - `trustedProxyAuth()` refuses to exist without proof that the proxy sent the request.
52
+ - `anyAuth()` combines hooks.
53
+ - `createJwtVerifier()` verifies tokens outside the server.
54
+ - **One Principal, everywhere a run goes.** It gains `roles`, `method`, and `claims`.
55
+ - Graph and workflow runs take a `principal`, which nodes read as `context.principal`. Its tenant
56
+ becomes the run's, and its subject is recorded on every checkpoint and in the run's trace.
57
+ - `graphAssistant()` passes the authenticated caller on.
58
+ - Tools receive a `ToolContext` as the second argument of `execute`: the principal, the thread
59
+ and tenant, the run's signal, and the tenant's store.
60
+ - Middleware and permission policies see the principal too.
61
+ - **Sparse retrieval as a contract.** A `SparseRetriever` is keyword search that takes writes, so it
62
+ fuses with vector search and stays in step with a vector store. `KeywordIndex` is one. Two more
63
+ ship:
64
+ - `PostgresKeywordIndex` (`nexus-ai-pro/postgres/fulltext`) uses Postgres full-text search, with a
65
+ GIN index and ranking in the database. With `shared`, it searches a pgvector table through a
66
+ generated column, so hybrid search keeps one copy of the data.
67
+ - `ElasticsearchKeywordIndex` (`nexus-ai-pro/rag/elasticsearch`) works with Elasticsearch or
68
+ OpenSearch over the REST API, with no client library.
69
+ - **Rerankers**, on the new `nexus-ai-pro/rag/rerankers`, with no SDK:
70
+ - `cohereReranker()`, `voyageReranker()`, and `jinaReranker()` for hosted APIs;
71
+ - `teiReranker()` for a self-hosted cross-encoder;
72
+ - `httpReranker()` for any other API;
73
+ - `crossEncoderReranker()` for a model run in process.
74
+ - **Durable ingestion**, on the new `nexus-ai-pro/rag/pipeline`. `createIngestionPipeline()` keeps a
75
+ manifest in any `Store`: each document's content version, its chunking, the embedding model, and
76
+ each chunk's version. A run:
77
+ - embeds only new and changed chunks; chunks are named by their text, so one that only moved keeps
78
+ its embedding;
79
+ - deletes the chunks and documents that are gone;
80
+ - keeps keyword indexes in step;
81
+ - writes to the stores before the manifest.
82
+
83
+ `ingestionExecutor()` runs it as a durable operation, and a retry starts at the document the
84
+ failed attempt stopped on.
85
+ - **A deep-agent preset**, on the new `nexus-ai-pro/deep-agent`. `createDeepAgent()` is
86
+ `createAgent()` composed with:
87
+ - a plan, kept in the transcript;
88
+ - a sandboxed workspace, with `edit_file` for precise changes and commands granted by name;
89
+ - helpers behind one `delegate` tool, which never get a tool that needs approval;
90
+ - skills loaded on demand;
91
+ - instruction files and context offloading;
92
+ - context editing, optional tool selection, and a default permission policy.
93
+
94
+ It returns the same compiled graph as any agent; it is not a new engine.
95
+ - `processSandbox()` loads Node's modules when first used, so `nexus-ai-pro/agent/sandbox`, and a
96
+ deep agent over a container sandbox, import on any runtime.
97
+
98
+ ### Fixed
99
+
100
+ - `MemoryStore` swept every item for expiry on every write, so writing n items took time
101
+ proportional to n². It now sweeps only once an item has a time to live, and at most once a second.
102
+ Expired items were already hidden from reads.
103
+
104
+ - `limitToolCalls()` counted calls in memory shared by every run of an agent, so one thread's calls
105
+ used up another's limit, and the count never reset. It now counts per run from the transcript.
106
+ - A subgraph run through `asNode()` now runs for its parent's tenant, so its store is the tenant's
107
+ view, as the parent's is.
108
+ - `sandboxTools()` file tools accept a path that names the mount, such as `/workspace/notes.txt`,
109
+ as well as one relative to it, and say in their descriptions which to use.
110
+
111
+ ### Proof
112
+
113
+ - In a real agent tool loop, a write outside the workspace and a request to an unlisted host are
114
+ refused before their tools run, and a granted command that needs approval waits for a person.
115
+ - The process sandbox passes the conformance suite, and a sandbox that leaks the host's environment
116
+ and writes outside its root fails exactly those two checks.
117
+ - An agent over 150 tools completes 40 tasks, a third of them needing two tools, with
118
+ `toolSelector()` at equal quality: every task succeeds either way. Input tokens per task fall from
119
+ about 64,000 to about 5,200, and latency by about 70%: the model's time computed from
120
+ each request's size, plus the agent's own measured time. Two experiments, reported by
121
+ `compareExperiments()`, show it.
122
+ - Each middleware is proved in a real agent loop. For example:
123
+ - a charge that timed out is never retried;
124
+ - an approver who raises a refund past what the policy allows has the edit refused;
125
+ - two agents sharing a store share one thread budget;
126
+ - a 45,000-character tool result reaches the transcript as a preview and a file.
127
+ - A JWT from a test identity provider is verified against its key set and reaches an agent's tool
128
+ call as a `Principal` with its subject, tenant, roles, and scopes.
129
+ - The tool's writes land in that tenant's view of the store.
130
+ - The checkpoint records who ran the step.
131
+ - Another tenant's token gets 404 for the thread.
132
+ - Expired, wrong-audience, wrong-issuer, unsigned, and forged tokens are refused.
133
+ - The provider rotates its key with one refetch.
134
+ - On the stored support dataset, on real Postgres with full-text search and pgvector, hybrid retrieval
135
+ beats vector-only: recall@5 0.71 → 0.97 and mean reciprocal rank 0.51 → 0.71, both with a 95%
136
+ interval above zero. A hosted reranker and a local cross-encoder, compared in the same experiment,
137
+ each lift mean reciprocal rank to 0.97. One contract test runs against all four keyword indexes.
138
+ - An ingestion of 100,000 documents dies halfway inside a durable operation. Its retry starts at the
139
+ document the first attempt stopped on, and embeds no chunk that had finished. A second run after
140
+ ten documents are edited embeds exactly ten chunks and deletes their old versions.
141
+ - A deep agent completes a multi-step task on a fixture repository inside the reference sandbox. It
142
+ plans, runs the failing test, reads and edits the bug, verifies the fix, has a helper review it,
143
+ loads a skill, and records the change; the test then passes outside the agent. Along the way, a
144
+ 30,000-character log is offloaded to a file. The default policy refuses an unlisted command, a
145
+ chained command, a write outside the workspace, and an ungranted tool.
146
+
7
147
  ## [2.2.0] - 2026-10-05
8
148
 
9
149
  Production scale and safe upgrades. Every database schema is versioned, so an upgrade applies only
@@ -1637,7 +1777,8 @@ shared policy vocabulary is in place for a future release that revisits this.
1637
1777
  - URL fetching rejects unsafe private-network targets and limits response reads.
1638
1778
  - CLI and audit findings no longer disclose detected secret values.
1639
1779
 
1640
- [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.2.0...HEAD
1780
+ [Unreleased]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.3.0...HEAD
1781
+ [2.3.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.2.0...v2.3.0
1641
1782
  [2.2.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.1.0...v2.2.0
1642
1783
  [2.1.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v2.0.0...v2.1.0
1643
1784
  [2.0.0]: https://github.com/mkhitar-abrahamyan/nexus-ai/compare/v1.25.0...v2.0.0
package/README.md CHANGED
@@ -8,7 +8,7 @@ team can share.
8
8
  Import only what you use. The root import is the client and nothing else, and every other capability
9
9
  has its own entry point with a size budget CI enforces; [the packaging guide](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/packaging.md)
10
10
  publishes what each one costs. The package has no required dependencies: a graph-only application
11
- loads 78 KB, and installs nothing but this package.
11
+ loads 80 KB, and installs nothing but this package.
12
12
 
13
13
  - NPM: https://www.npmjs.com/package/nexus-ai-pro
14
14
  - GitHub: https://github.com/mkhitar-abrahamyan/nexus-ai
@@ -132,7 +132,8 @@ from the doc comments. The guides live in the repository, so these links go to G
132
132
  | [Providers and routing](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/providers.md) | The twelve completion providers, routing and failover, the model registry |
133
133
  | [Guardrails](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/security.md) | Injection, PII, secrets, schema validation, output redaction |
134
134
  | [Graphs](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/graphs.md) | Parallel branches, interrupts, checkpoints, time travel, subgraphs, caching, diagrams |
135
- | [Agents and tools](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/agents.md) | Graph-based agents with approvals and middleware, and the simple tool loop |
135
+ | [Agents and tools](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/agents.md) | Graph-based agents with approvals, capabilities and permission policies, a sandbox contract, the middleware catalog, and the simple tool loop |
136
+ | [The deep-agent preset](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/deep-agents.md) | Long, multi-step agents: a plan, a sandboxed workspace, helpers, skills, and a context that stays small |
136
137
  | [Long-term memory](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/memory.md) | Namespaced memory with semantic search, in memory or Redis |
137
138
  | [MCP](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/mcp.md) | Borrowing tools from MCP servers, a registry of many, and serving your own |
138
139
  | [Traces](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/docs/tracing.md) | Run trees, queries, feedback, comparison, and alerts |
@@ -251,12 +252,16 @@ Shipped so far:
251
252
  backend, one event stream, and dashboard rollups;
252
253
  - 2.2 production scale and safe upgrades: versioned database migrations, queue claims and routing
253
254
  that cost the same at any size, a circuit breaker that counts across workers, one tenant model
254
- across every store, and `nexus doctor` to check a deployment before it serves traffic.
255
+ across every store, and `nexus doctor` to check a deployment before it serves traffic;
256
+ - 2.3 agents that act safely, on production retrieval: capabilities and permission policies, a
257
+ sandbox contract, a middleware catalog, server authentication with one principal on every run,
258
+ keyword search in Postgres and Elasticsearch with rerankers, durable ingestion, and a deep-agent
259
+ preset.
255
260
 
256
261
  Upgrading from 1.x: `npx nexus migrate src --write` moves your imports, and
257
262
  [MIGRATING.md](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/MIGRATING.md) covers the rest.
258
- Next, 2.3 gives agents a real security boundary — capabilities, permissions, and a sandbox contract —
259
- with a complete middleware catalog, and retrieval that works at production scale. The [roadmap](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/ROADMAP.md) lists the
263
+ Next, 2.4 takes the experimental surfaces out of experimental with evidence, adds an adapter kit, and
264
+ speaks the agent protocols beyond MCP from a portable kernel. The [roadmap](https://github.com/mkhitar-abrahamyan/nexus-ai/blob/main/ROADMAP.md) lists the
260
265
  releases after it.
261
266
 
262
267
  ## Before Production
@@ -0,0 +1,39 @@
1
+ import type { ToolDefinition } from '../types/messages.js';
2
+ /**
3
+ * Capabilities: what a tool does to the world, written as strings a policy can decide on.
4
+ *
5
+ * | Capability | Means |
6
+ * | --- | --- |
7
+ * | `filesystem:read`, `filesystem:read:<path>` | Reads files, or one path |
8
+ * | `filesystem:write`, `filesystem:write:<path>` | Writes, creates, or deletes files |
9
+ * | `network`, `network:<host or URL>` | Opens a network connection, to a host when given |
10
+ * | `shell`, `shell:<command line>` | Runs a shell command |
11
+ * | `code` | Runs code it was given, as an interpreter does |
12
+ * | `<name>`, `<name>:<detail>` | Anything the application names, such as `payments:refund` |
13
+ *
14
+ * A tool that declares `capabilities: []` does nothing to the world; one that declares none at all
15
+ * is undeclared, and a policy decides on it as such.
16
+ */
17
+ /** One capability, split into its parts. */
18
+ export interface ParsedCapability {
19
+ /** `filesystem`, `network`, `shell`, `code`, or the application's own name. */
20
+ kind: string;
21
+ /** `read` or `write`, for the filesystem. */
22
+ access?: 'read' | 'write';
23
+ /** The path, host, command, or detail, when the capability names one. */
24
+ target?: string;
25
+ }
26
+ /** Splits a capability string into its kind, access, and target. */
27
+ export declare function parseCapability(capability: string): ParsedCapability;
28
+ /**
29
+ * The capabilities one call of a tool declares: its static list, or what its function computes from
30
+ * the call's arguments. `undefined` means undeclared — no list, or a function that threw — which is
31
+ * not the same as `[]`, a tool that declares it does nothing to the world.
32
+ */
33
+ export declare function capabilitiesOf(tool: Pick<ToolDefinition, 'capabilities'> | undefined, args: Record<string, unknown>): string[] | undefined;
34
+ /**
35
+ * Whether a capability is sensitive: anything but reading files. A pure tool declares
36
+ * `capabilities: []`; whatever it does declare, a write, a command, code, the network, or an
37
+ * effect of the application's own such as `payments`, changes the world or leaves the process.
38
+ */
39
+ export declare function isSensitiveCapability(capability: string): boolean;
@@ -0,0 +1,36 @@
1
+ export function parseCapability(capability) {
2
+ const text = capability.trim();
3
+ const colon = text.indexOf(':');
4
+ const kind = (colon < 0 ? text : text.slice(0, colon)).toLowerCase();
5
+ const rest = colon < 0 ? undefined : text.slice(colon + 1);
6
+ if (kind === 'filesystem') {
7
+ if (!rest)
8
+ return { kind };
9
+ const [access, ...path] = rest.split(':');
10
+ const parsed = { kind };
11
+ if (access === 'read' || access === 'write')
12
+ parsed.access = access;
13
+ const target = parsed.access ? path.join(':') : rest;
14
+ if (target)
15
+ parsed.target = target;
16
+ return parsed;
17
+ }
18
+ return rest === undefined || rest === '' ? { kind } : { kind, target: rest };
19
+ }
20
+ export function capabilitiesOf(tool, args) {
21
+ const declared = tool?.capabilities;
22
+ if (!declared)
23
+ return undefined;
24
+ if (typeof declared !== 'function')
25
+ return [...declared];
26
+ try {
27
+ return [...declared(args)];
28
+ }
29
+ catch {
30
+ return undefined;
31
+ }
32
+ }
33
+ export function isSensitiveCapability(capability) {
34
+ const parsed = parseCapability(capability);
35
+ return !(parsed.kind === 'filesystem' && parsed.access === 'read');
36
+ }
@@ -1,9 +1,11 @@
1
1
  import type { OperationLifecycleLike } from '../types/lifecycle.js';
2
2
  import { type CompiledGraph } from '../graph/graph.js';
3
- import type { GraphCheckpointer, NodeContext } from '../types/graph.js';
3
+ import type { GraphCheckpointer, InterruptRequest, NodeContext } from '../types/graph.js';
4
4
  import type { CompletionRequest, Message, ToolDefinition } from '../types/messages.js';
5
5
  import type { NexusResponse, StreamChunk } from '../types/response.js';
6
+ import type { Principal } from '../types/principal.js';
6
7
  import type { Store } from '../types/store.js';
8
+ import type { PermissionPolicyLike } from './permissions.js';
7
9
  /**
8
10
  * An agent as a graph.
9
11
  *
@@ -28,6 +30,10 @@ export interface AgentToolCall {
28
30
  name: string;
29
31
  /** Arguments, parsed from the model's JSON. */
30
32
  args: Record<string, unknown>;
33
+ /** What the call declares it does, when the tool declares capabilities. */
34
+ capabilities?: readonly string[];
35
+ /** Why the permission policy asked for approval, when it did. */
36
+ permission?: string;
31
37
  }
32
38
  /** What an approver may answer: allow, refuse with a reason, or allow with corrected arguments. */
33
39
  export type AgentApproval = boolean | {
@@ -42,24 +48,58 @@ export interface AgentApprovalPolicy {
42
48
  /** The question an operator sees. Defaults to naming the tool and its arguments. */
43
49
  reason?: (call: AgentToolCall) => string;
44
50
  }
51
+ /** What every middleware hook can read about the step it runs in. */
52
+ export interface AgentMiddlewareContext {
53
+ /** The agent's state at this step: the whole transcript, tool calls and results included. */
54
+ state: AgentState;
55
+ /** Long-term memory, when the agent has a store. */
56
+ store?: Store;
57
+ /** The thread the run belongs to. */
58
+ threadId: string;
59
+ /** The tenant the run is for, when it has one. */
60
+ tenantId?: string;
61
+ /** Who the run is for, when it has a principal: the caller the server authenticated. */
62
+ principal?: Readonly<Principal>;
63
+ /** Aborted when the run is cancelled or the step times out. A hook that waits should stop with it. */
64
+ signal: AbortSignal;
65
+ /**
66
+ * Suspends the run until a person answers, as `interruptOn` does. When the run resumes, the step
67
+ * runs again and this returns the answer instead of suspending.
68
+ */
69
+ interrupt<T = unknown>(request: InterruptRequest): T;
70
+ }
71
+ /** What `wrapModelCall` can read, and how it ends the run without calling the model. */
72
+ export interface AgentModelCallContext extends AgentMiddlewareContext {
73
+ /**
74
+ * Ends the run with this answer and `stopReason: 'stopped'`, without another model call. Return
75
+ * what it returns: `return context.stop('The model call limit was reached.')`.
76
+ */
77
+ stop(answer: string): NexusResponse;
78
+ }
45
79
  /** Hooks around the agent's model calls and tool calls. Each hook is optional. */
46
80
  export interface AgentMiddleware {
47
81
  /** Names the middleware in traces and errors. */
48
82
  name?: string;
49
83
  /** Adjusts the request before it is sent: trim history, add context, swap the model. */
50
- beforeModel?(context: {
84
+ beforeModel?(context: AgentMiddlewareContext & {
51
85
  request: CompletionRequest;
52
- state: AgentState;
53
- store?: Store;
54
86
  }): CompletionRequest | void | Promise<CompletionRequest | void>;
55
87
  /** Inspects or replaces the response: redact, validate, count. */
56
- afterModel?(context: {
88
+ afterModel?(context: AgentMiddlewareContext & {
57
89
  response: NexusResponse;
58
- state: AgentState;
59
- store?: Store;
60
90
  }): NexusResponse | void | Promise<NexusResponse | void>;
61
- /** Wraps a tool call, so a policy can log it, time it, or refuse it. */
62
- wrapToolCall?(call: AgentToolCall, next: () => Promise<AgentToolResult>): Promise<AgentToolResult> | AgentToolResult;
91
+ /**
92
+ * Wraps the model call itself, after every `beforeModel` and before every `afterModel`. `next`
93
+ * sends a request and may be called again, with the same request or a changed one, to retry or to
94
+ * fall back to another model. The first middleware is the outermost.
95
+ */
96
+ wrapModelCall?(request: CompletionRequest, next: (request: CompletionRequest) => Promise<NexusResponse>, context: AgentModelCallContext): Promise<NexusResponse> | NexusResponse;
97
+ /**
98
+ * Wraps a tool call, so a policy can log it, time it, retry it, or refuse it. `next` runs the
99
+ * rest of the chain and may be called again; given a call, it runs that call's arguments instead,
100
+ * and the permission policy decides them again first. The first middleware is the outermost.
101
+ */
102
+ wrapToolCall?(call: AgentToolCall, next: (call?: AgentToolCall) => Promise<AgentToolResult>, context: AgentMiddlewareContext): Promise<AgentToolResult> | AgentToolResult;
63
103
  }
64
104
  /** What a tool call produced. */
65
105
  export interface AgentToolResult {
@@ -88,6 +128,13 @@ export interface CreateAgentOptions {
88
128
  interruptOn?: Record<string, AgentApprovalPolicy | true>;
89
129
  /** Hooks around model and tool calls, applied in order. */
90
130
  middleware?: AgentMiddleware[];
131
+ /**
132
+ * Decides every tool call from the capabilities it declares, before it runs: allow it, deny it,
133
+ * or interrupt for a person's approval, as `interruptOn` does. A denied call reaches the model as a
134
+ * failed tool result naming the reason, and never runs. See `permissionPolicy()` in
135
+ * `nexus-ai-pro/agent/permissions`.
136
+ */
137
+ permissions?: PermissionPolicyLike;
91
138
  /** Long-term memory, available to tools and middleware as `store`. */
92
139
  store?: Store;
93
140
  /**
@@ -136,8 +183,11 @@ export type AgentState = {
136
183
  /** Why the agent stopped. */
137
184
  stopReason?: AgentStopReason;
138
185
  };
139
- /** `completed` when the model answered, `max_iterations` when it ran out of model calls. */
140
- export type AgentStopReason = 'completed' | 'max_iterations';
186
+ /**
187
+ * `completed` when the model answered, `max_iterations` when it ran out of model calls, `stopped`
188
+ * when a middleware ended the run through `context.stop()`.
189
+ */
190
+ export type AgentStopReason = 'completed' | 'max_iterations' | 'stopped';
141
191
  /** An agent: a compiled graph over the agent's channels. */
142
192
  export type AgentGraph = CompiledGraph<AgentChannels>;
143
193
  /** Wraps a question into the state an agent starts from. */
@@ -1,6 +1,7 @@
1
1
  import { appendList, counter, lastValue } from '../graph/channels.js';
2
2
  import { createGraph } from '../graph/graph.js';
3
3
  import { Command, END, Send } from '../types/graph.js';
4
+ import { capabilitiesOf } from './capabilities.js';
4
5
  import { ToolExecutor, toolMessageContent } from './tool.js';
5
6
  const agentChannels = () => ({
6
7
  messages: appendList(),
@@ -34,24 +35,47 @@ export function createAgent(options) {
34
35
  ...(options.responseFormat ? { responseFormat: options.responseFormat } : {}),
35
36
  ...(options.metadata ? { metadata: options.metadata } : {}),
36
37
  };
38
+ const hooks = hookContext(context, state, options.store);
37
39
  for (const item of middleware) {
38
- request = (await item.beforeModel?.({ request, state, store: options.store })) ?? request;
40
+ request = (await item.beforeModel?.({ ...hooks, request })) ?? request;
39
41
  }
40
- let response = options.streamTokens && options.client.stream
41
- ? await streamed(options.client.stream(request), (chunk) => context.message(chunk))
42
- : await options.client.complete(request);
43
- if (!(options.streamTokens && options.client.stream) && response.content) {
42
+ const streaming = Boolean(options.streamTokens && options.client.stream);
43
+ const send = (next) => streaming && options.client.stream
44
+ ? streamed(options.client.stream(next), (chunk) => context.message(chunk))
45
+ : options.client.complete(next);
46
+ const stops = new WeakSet();
47
+ const modelContext = {
48
+ ...hooks,
49
+ stop: (answer) => {
50
+ const stopped = stopResponse(answer, request.model);
51
+ stops.add(stopped);
52
+ return stopped;
53
+ },
54
+ };
55
+ const wrapped = middleware.reduceRight((next, item) => {
56
+ const wrap = item.wrapModelCall?.bind(item);
57
+ return wrap ? (current) => Promise.resolve(wrap(current, next, modelContext)) : next;
58
+ }, send);
59
+ let response = await wrapped(request);
60
+ const stopped = stops.has(response);
61
+ if ((!streaming || stopped) && response.content) {
44
62
  context.message({ content: response.content });
45
63
  }
46
64
  for (const item of middleware) {
47
- response = (await item.afterModel?.({ response, state, store: options.store })) ?? response;
65
+ response = (await item.afterModel?.({ ...hooks, response })) ?? response;
48
66
  }
49
67
  const assistant = {
50
68
  role: 'assistant',
51
69
  content: response.content,
52
- ...(response.toolCalls?.length ? { toolCalls: response.toolCalls } : {}),
70
+ ...(response.toolCalls?.length && !stopped ? { toolCalls: response.toolCalls } : {}),
53
71
  };
54
- const calls = response.toolCalls ?? [];
72
+ const calls = stopped ? [] : (response.toolCalls ?? []);
73
+ if (stopped) {
74
+ return new Command({
75
+ update: { messages: [assistant], iterations: 1, answer: response.content, stopReason: 'stopped' },
76
+ goto: END,
77
+ });
78
+ }
55
79
  if (calls.length === 0) {
56
80
  return new Command({
57
81
  update: { messages: [assistant], iterations: 1, answer: response.content, stopReason: 'completed' },
@@ -76,27 +100,82 @@ export function createAgent(options) {
76
100
  return { messages: [toolMessage(call, { ok: false, error: parsed.error })] };
77
101
  }
78
102
  let args = parsed.args;
79
- const description = { id: call.id, name: call.function.name, args };
103
+ const definition = executor.get(call.function.name);
104
+ const capabilities = capabilitiesOf(definition, args);
105
+ const description = {
106
+ id: call.id,
107
+ name: call.function.name,
108
+ args,
109
+ ...(capabilities ? { capabilities } : {}),
110
+ };
111
+ const refuse = (error) => {
112
+ context.tool({ phase: 'error', id: call.id, name: description.name, error });
113
+ return { messages: [toolMessage(call, { ok: false, error })] };
114
+ };
115
+ let permission;
116
+ if (options.permissions) {
117
+ const verdict = await options.permissions.decide({
118
+ tool: description.name,
119
+ args,
120
+ capabilities,
121
+ ...(context.principal ? { principal: context.principal } : {}),
122
+ });
123
+ if (verdict.decision === 'deny')
124
+ return refuse(`Permission denied: ${verdict.reason}`);
125
+ if (verdict.decision === 'ask')
126
+ permission = verdict.reason;
127
+ }
80
128
  const policy = approvals[description.name];
81
- if (policy) {
129
+ if (policy || permission) {
130
+ const asked = permission ? { ...description, permission } : description;
82
131
  const decision = context.interrupt({
83
- reason: (policy === true ? undefined : policy.reason?.(description)) ?? defaultReason(description),
84
- payload: description,
132
+ reason: (policy && policy !== true ? policy.reason?.(asked) : undefined) ??
133
+ (permission ? `${defaultReason(asked)} (${permission})` : defaultReason(asked)),
134
+ payload: asked,
85
135
  });
86
136
  const verdict = normalizeApproval(decision);
87
- if (!verdict.approved) {
88
- return {
89
- messages: [toolMessage(call, { ok: false, error: verdict.reason ?? 'A human refused this tool call' })],
90
- };
91
- }
92
- if (verdict.args)
137
+ if (!verdict.approved)
138
+ return refuse(verdict.reason ?? 'A human refused this tool call');
139
+ if (verdict.args) {
93
140
  args = verdict.args;
141
+ if (options.permissions) {
142
+ const recheck = await options.permissions.decide({
143
+ tool: description.name,
144
+ args,
145
+ capabilities: capabilitiesOf(definition, args),
146
+ });
147
+ if (recheck.decision === 'deny')
148
+ return refuse(`Permission denied: ${recheck.reason}`);
149
+ }
150
+ }
94
151
  }
95
- const run = async () => executor.execute(description.name, args);
152
+ const run = async (current) => {
153
+ if (options.permissions && current.args !== args) {
154
+ const recheck = await options.permissions.decide({
155
+ tool: description.name,
156
+ args: current.args,
157
+ capabilities: capabilitiesOf(definition, current.args),
158
+ });
159
+ if (recheck.decision === 'deny')
160
+ return { ok: false, error: `Permission denied: ${recheck.reason}` };
161
+ }
162
+ return executor.execute(description.name, current.args, {
163
+ toolCallId: call.id,
164
+ threadId: context.threadId,
165
+ ...(context.tenantId !== undefined ? { tenantId: context.tenantId } : {}),
166
+ ...(context.principal ? { principal: context.principal } : {}),
167
+ signal: context.signal,
168
+ ...(context.store ? { store: context.store } : {}),
169
+ });
170
+ };
96
171
  context.tool({ phase: 'start', id: call.id, name: description.name, args });
97
- const result = await middleware.reduceRight((next, item) => item.wrapToolCall
98
- ? () => Promise.resolve(item.wrapToolCall?.({ ...description, args }, next))
99
- : next, run)();
172
+ const hooks = hookContext(context, context.state, options.store);
173
+ const result = await middleware.reduceRight((next, item) => {
174
+ const wrap = item.wrapToolCall?.bind(item);
175
+ return wrap
176
+ ? (current) => Promise.resolve(wrap(current, (changed) => next(changed ?? current), hooks))
177
+ : next;
178
+ }, run)({ ...description, args });
100
179
  context.tool(result.ok
101
180
  ? { phase: 'result', id: call.id, name: description.name, result: result.result }
102
181
  : { phase: 'error', id: call.id, name: description.name, error: result.error ?? 'The tool failed' });
@@ -111,6 +190,12 @@ export function createAgent(options) {
111
190
  ...(options.store ? { store: options.store } : {}),
112
191
  ...(options.name ? { name: options.name } : {}),
113
192
  ...(options.lifecycle ? { lifecycle: options.lifecycle, lifecycleFamily: 'agent' } : {}),
193
+ tools: executor.list().map((definition) => ({
194
+ name: definition.name,
195
+ ...(Array.isArray(definition.capabilities) ? { capabilities: [...definition.capabilities] } : {}),
196
+ ...(typeof definition.capabilities === 'function' ? { dynamic: true } : {}),
197
+ approval: approvals[definition.name] ? 'interrupt' : options.permissions ? 'policy' : 'none',
198
+ })),
114
199
  });
115
200
  }
116
201
  export function agentAsTool(options) {
@@ -165,6 +250,35 @@ function parseArguments(raw) {
165
250
  return { ok: false, error: `Tool arguments are not valid JSON (${reason}); the tool was not run` };
166
251
  }
167
252
  }
253
+ function hookContext(context, state, store) {
254
+ return {
255
+ state,
256
+ ...((context.store ?? store) ? { store: context.store ?? store } : {}),
257
+ threadId: context.threadId,
258
+ ...(context.tenantId ? { tenantId: context.tenantId } : {}),
259
+ ...(context.principal ? { principal: context.principal } : {}),
260
+ signal: context.signal,
261
+ interrupt: (request) => context.interrupt(request),
262
+ };
263
+ }
264
+ function stopResponse(answer, model) {
265
+ return {
266
+ content: answer,
267
+ role: 'assistant',
268
+ finishReason: 'stop',
269
+ meta: {
270
+ requestId: '',
271
+ providerUsed: 'none',
272
+ modelUsed: model,
273
+ latencyMs: 0,
274
+ tokensInput: 0,
275
+ tokensOutput: 0,
276
+ tokensSaved: 0,
277
+ cacheHit: false,
278
+ guardrailsApplied: ['stopped-by-middleware'],
279
+ },
280
+ };
281
+ }
168
282
  async function streamed(chunks, onChunk) {
169
283
  let content = '';
170
284
  const toolCalls = [];