diff --git a/CHANGELOG.md b/CHANGELOG.md index ce327b5..efc1b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +- Added framework-neutral agent middleware with bounded context compilation, + model-facing JSON Schema tools, identity-bound Agent Intent dispatch, + durable turn recording, and embedded, in-process production, and HTTP + adapters. + ## 1.0.0 - Declared Agent Intent 1.0 and the documented TypeScript, HTTP, MCP, CLI, diff --git a/README.md b/README.md index d1b0517..8365992 100644 --- a/README.md +++ b/README.md @@ -143,12 +143,12 @@ stale or inconsistent published evidence. | Application-authored tables | 8 | 0 | | Total operated tables | 8 | 18 | | Median database footprint | 540,672 bytes | 1,572,864 bytes | -| Informational median runtime | 63.45 ms | 970.05 ms | +| Informational median runtime | 52.02 ms | 865.57 ms | The result is deliberately not presented as a universal win: - PostgreSQL matches the kernel on correctness. -- The smaller adapter reuses 930 lines of shipped scenario code and a 14,973 +- The smaller adapter reuses 930 lines of shipped scenario code and a 16,424 line dependency. It is not an equal from-scratch implementation comparison. - The kernel operates more tables, uses more storage, and takes substantially longer in this deterministic smoke run. @@ -245,6 +245,30 @@ execution command. See the [API Reference](docs/API.md). +### Agent middleware + +Agentic Data Kernel can be used as lifecycle middleware between an agent host +and its model. The host receives bounded durable context and JSON Schema tools; +model tool calls are translated into identity-bound Agent Intent operations, +and approved turns can be recorded as durable artifacts that are encrypted by +the production profile. + +```ts +const session = createEmbeddedAgentMiddleware(kernel, principal).beginRun({ + runId: "run:incident-1001", +}); + +const { context, tools } = await session.prepareModelInput({ + query: "why is checkout failing?", + workflow: { instanceId: "incident:1001" }, +}); +``` + +Production agents can bind an authenticated `ProductionKernel` principal or +use the remote HTTP adapter. Direct MCP remains available when the model host +already owns context assembly and turn lifecycle. See +[Agent Middleware](docs/AGENT_MIDDLEWARE.md). + ## When it fits Use Agentic Data Kernel when several of these are true: diff --git a/benchmarks/sre/results/report.md b/benchmarks/sre/results/report.md index 5238a8b..a3cc772 100644 --- a/benchmarks/sre/results/report.md +++ b/benchmarks/sre/results/report.md @@ -2,9 +2,9 @@ Generated from `summary.json`. -Source revision: `027bf5d55a8fd19a00f0477d162c7aaa779493cf` +Source revision: `e8b97677131117a403bba0202021d1b850598f00` -Source hash: `1744425ccd21f71ad1b37af903a622512d5968ac3dea712381c746c5ead2eda6` +Source hash: `59eec5ed80b8c4a470e9959b969a131197281f355df8e93b001bc843cfeb583e` ## Correctness @@ -24,7 +24,7 @@ Both variants must resolve every run with one delivery and one reconciliation. The adapter delegates to the shipped SRE scenario, which contains 930 nonblank TypeScript source lines inside the -dependency. The full kernel dependency contains 14973 +dependency. The full kernel dependency contains 16424 nonblank TypeScript source lines. The benchmark runner and engine-specific audit verification contain @@ -44,8 +44,8 @@ operated, or upgraded. | Variant | Median milliseconds | | --- | ---: | -| Conventional PostgreSQL | 63.45 | -| Agentic Data Kernel | 970.05 | +| Conventional PostgreSQL | 52.02 | +| Agentic Data Kernel | 865.57 | Runtime is not a headline metric. The variants perform different work and this deterministic smoke benchmark is not a latency study. diff --git a/benchmarks/sre/results/summary.json b/benchmarks/sre/results/summary.json index 6aee84a..74f09e4 100644 --- a/benchmarks/sre/results/summary.json +++ b/benchmarks/sre/results/summary.json @@ -4,8 +4,8 @@ "environment": { "node": "v22.22.2", "postgres": "18.6 (Debian 18.6-1.pgdg12+2)", - "commit": "027bf5d55a8fd19a00f0477d162c7aaa779493cf", - "sourceHash": "1744425ccd21f71ad1b37af903a622512d5968ac3dea712381c746c5ead2eda6" + "commit": "e8b97677131117a403bba0202021d1b850598f00", + "sourceHash": "59eec5ed80b8c4a470e9959b969a131197281f355df8e93b001bc843cfeb583e" }, "runs": [ { @@ -31,7 +31,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 63.448800000000006 + "durationMs": 70.5781 }, "operatedTables": 8, "databaseBytes": 540672 @@ -59,7 +59,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 933.0171 + "durationMs": 926.5473999999999 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -87,7 +87,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 53.32709999999997 + "durationMs": 52.016099999999824 }, "operatedTables": 8, "databaseBytes": 540672 @@ -115,7 +115,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 970.0511999999999 + "durationMs": 865.5692000000004 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -143,7 +143,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 64.1876000000002 + "durationMs": 50.6215000000002 }, "operatedTables": 8, "databaseBytes": 540672 @@ -171,7 +171,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 1042.3940000000002 + "durationMs": 835.1620999999996 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -266,7 +266,7 @@ "authoredTables": 0, "operatedTables": 18, "scenarioSourceLines": 930, - "dependencySourceLines": 14973 + "dependencySourceLines": 16424 } }, "benchmarkHarness": { @@ -277,8 +277,8 @@ "agenticDataKernelMedian": 1572864 }, "runtimeMillisecondsInformational": { - "conventionalPostgresMedian": 63.448800000000006, - "agenticDataKernelMedian": 970.0511999999999 + "conventionalPostgresMedian": 52.016099999999824, + "agenticDataKernelMedian": 865.5692000000004 }, "explanationQuestions": 9, "claims": { diff --git a/docs/AGENT_MIDDLEWARE.md b/docs/AGENT_MIDDLEWARE.md new file mode 100644 index 0000000..3d1a9d7 --- /dev/null +++ b/docs/AGENT_MIDDLEWARE.md @@ -0,0 +1,464 @@ +# Agent Middleware + +Agentic Data Kernel can sit between an agent host and its model as a durable +knowledge, workflow, and effect middleware. + +```text +user, event, or scheduler + | + v +agent host / orchestration loop + | | + | model request | Agent Intent 1.0 + v v +language model Agentic Data Middleware + | + +-- context compilation + +-- model-visible semantic tools + +-- bound tenant, principal, and purpose + +-- idempotent operation execution + +-- durable turn artifacts and receipts + | + v + Agentic Data Kernel + | | + v v + PostgreSQL effect worker + | + v + external systems +``` + +The model reasons. The host owns the model loop. The middleware prepares +bounded context and translates model tool calls into authenticated Agent +Intent operations. The kernel owns durable state, invariants, receipts, and +external-effect recovery. + +## Choose an integration mode + +| Mode | Use when | +| --- | --- | +| Direct MCP | The model host already supports MCP and only needs kernel tools | +| Embedded middleware | A local or single-process agent uses the SQLite profile | +| In-process production middleware | The agent runs beside a configured `ProductionKernel` | +| HTTP production middleware | The agent host calls a separately deployed production API | + +Direct MCP is the smallest integration. The middleware API adds lifecycle +features around the same Agent Intent operations: + +- a host-bound identity that model arguments cannot replace; +- standard JSON Schema tool definitions; +- context compilation before a model call; +- idempotent tool-call dispatch; +- durable recording of approved turn content. + +## Embedded agent + +```ts +import { + AgenticKernel, + SqliteStore, + createEmbeddedAgentMiddleware, +} from "agentic-data-kernel"; + +const store = new SqliteStore(".data/agent.db"); +const kernel = new AgenticKernel(store); + +const middleware = createEmbeddedAgentMiddleware(kernel, { + tenantId: "operations", + principalId: "incident-agent", + purpose: "incident-response", +}); + +const session = middleware.beginRun({ + runId: "run:checkout-1001", + taskId: "incident:checkout-1001", + conversationId: "conversation:checkout-1001", +}); +``` + +`beginRun` binds host metadata to one middleware session. It does not create a +workflow automatically. When the task needs durable state transitions, the +agent creates a workflow explicitly through `execute_operation`. +The host can read the bound run metadata with `session.runInfo()`. +Run, task, conversation, turn, call, request, and middleware idempotency +identifiers reject control characters and are bounded in length. + +Run the complete embedded example: + +```powershell +npm run build +npm run example:agent +``` + +## Prepare a model request + +```ts +const modelInput = await session.prepareModelInput({ + query: "why is checkout failing?", + resolutions: [ + { + subjectEntityId: "service:checkout", + predicate: "primary_cause", + policy: "highest_authority", + }, + ], + workflow: { + instanceId: "incident:checkout-1001", + }, + effects: { + instanceId: "incident:checkout-1001", + limit: 10, + }, + traces: [ + { + target: { + type: "workflow_revision", + instanceId: "incident:checkout-1001", + revision: 3, + }, + maxDepth: 4, + }, + ], + maxCharacters: 24_000, +}); +``` + +The result contains: + +```ts +modelInput.context.modelContext; // bounded JSON text for the model +modelInput.context.sections; // complete structured host-side results +modelInput.context.includedReceiptIds; // complete evidence shown to the model +modelInput.context.partialReceiptIds; // evidence shown only as a preview +modelInput.context.omittedReceiptIds; // fetched but omitted from model context +modelInput.context.truncated; // whether modelContext was clipped +modelInput.tools; // framework-neutral JSON Schema tools +``` + +Context compilation executes ordinary read operations. It therefore preserves +tenant isolation, temporal semantics, conflict results, and durable receipts. +The defaults cap search and effect results at 10 and model context at 24,000 +characters. Per-call context can be capped from 1,000 through 100,000 +characters. + +When structured context exceeds the model limit, the host still receives the +complete `sections` array. `modelContext` retains every section header and +receipt ID, gives sections a `complete`, `truncated`, or `omitted` status, and +allocates bounded previews before using remaining space for complete results. +If section metadata alone cannot fit, context compilation fails explicitly +instead of silently dropping provenance. + +## Give the tools to a model + +`modelTools()` returns these semantic tools: + +```text +search_knowledge +resolve_claims +get_machine +list_effects +explain_trace +execute_operation +``` + +Each definition contains: + +```ts +{ + name: string; + title: string; + description: string; + inputSchema: Record; + readOnly: boolean; +} +``` + +For a provider using function-style tools, adapt them without changing their +schemas: + +```ts +const tools = session.modelTools().map((tool) => ({ + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.inputSchema, + }, +})); +``` + +The middleware does not depend on a model vendor or agent framework. The host +can map these definitions into any provider's equivalent tool format. + +## Execute model tool calls + +```ts +const toolResult = await session.invokeTool({ + callId: modelToolCall.id, + name: modelToolCall.name, + arguments: modelToolCall.arguments, +}); + +modelToolCall.respond(toolResult.modelContent); +``` + +`callId` becomes part of the envelope idempotency identity. Replaying the same +call with the same arguments returns the original durable result and receipt. +Reusing it for different content is rejected. + +Tool calls, direct host operations, and turn records use separate hashed +idempotency namespaces. A model-supplied logical key cannot collide with +middleware-owned turn persistence. + +The host-bound principal is inserted into the Agent Intent envelope by the +middleware. Model arguments never include: + +```text +tenantId +principalId +purpose +API key +``` + +`execute_operation` accepts one typed Agent Intent operation and an optional +logical idempotency key. By default, model-visible operations include entity and assertion writes, +knowledge reads, generic workflows, controlled effects, retail reservation +and payment requests, machine reads, and effect reads. + +Administrative or provider-owned operations are excluded by default: + +```text +put_artifact +seed_inventory +record_effect_outcome +record_payment_outcome +process_timers +``` + +Raw artifact writes remain host-owned so a model cannot forge middleware turn +artifacts. The host can call `session.execute(...)` for controlled evidence +ingestion, while `recordTurn(...)` uses the internal artifact path. + +Applications can only narrow the model surface further: + +```ts +const middleware = createEmbeddedAgentMiddleware(kernel, principal, { + allowedOperations: [ + "search", + "resolve", + "get_machine", + "list_effects", + "explain", + ], +}); +``` + +The tool catalog removes semantic tools whose underlying operation is not +allowed. Attempts to add operations outside the safe model allowlist fail +during middleware construction. The kernel still performs its normal +authorization and state checks. + +`session.execute(...)` is a host API and is not included in model tool +arguments. It can be used for trusted ingestion and administration when the +bound principal has the required authority. + +## Record an approved turn + +After the host decides which content should be durable: + +```ts +const recorded = await session.recordTurn({ + turnId: "turn-7", + input: { + role: "user", + content: "Investigate the checkout regression", + }, + output: { + role: "assistant", + content: "The latest deployment is the selected hypothesis.", + }, + contextReceiptIds: modelInput.context.includedReceiptIds, + toolCalls: [ + { + name: "resolve_claims", + arguments: { + subjectEntityId: "service:checkout", + predicate: "primary_cause", + }, + result: selectedCause, + receiptId: resolutionReceiptId, + }, + ], + metadata: { + model: "configured-by-host", + }, +}); +``` + +The turn is stored as an immutable +`application/vnd.agentic-data.agent-turn+json` artifact with a deterministic +identifier: + +```text +agent-turn: +``` + +Exact retries replay idempotently. Different content under the same run and +turn is rejected. Including principal identity prevents independent agents in +one tenant from colliding on the same run and turn labels. + +Turn content defaults to a 1,000,000-character limit and can be configured from +1,000 through 10,000,000 characters with `maxTurnCharacters`. + +Do not persist hidden chain-of-thought, credentials, or unreviewed sensitive +provider payloads. `recordTurn` stores only the JSON explicitly supplied by the +host. Use `sensitivity` and `retentionPolicy` to apply deployment-specific +handling. + +`beginRun({ metadata })` is transient host state and is not persisted. +Use `beginRun({ durableMetadata })` only for run metadata that should be copied +into every recorded turn. + +Turn artifacts are an audit record, not an automatic conversational-memory +feed. The host continues to manage its recent message window. Facts that +should influence future retrieval must be promoted deliberately into typed +assertions, with source artifacts and lineage when applicable. This prevents +every model utterance from silently becoming trusted long-term memory. + +## Production in-process adapter + +An agent running in the same process as the production kernel uses an already +authenticated principal: + +```ts +import { + createProductionAgentMiddleware, +} from "agentic-data-kernel/production"; + +const middleware = createProductionAgentMiddleware( + runtime.kernel, + authenticatedPrincipal, + { + allowedOperations: [ + "search", + "resolve", + "assert", + "create_workflow", + "advance_workflow", + "request_effect", + "get_machine", + "list_effects", + "explain", + ], + }, +); +``` + +Every operation revalidates the API key, tenant status, expiry, purpose, and +scope through `ProductionKernel`. The model cannot replace the authenticated +principal. + +## Production HTTP adapter + +An external agent host can use the deployed API without implementing Agent +Intent envelopes itself: + +```ts +import { + createProductionHttpAgentMiddleware, +} from "agentic-data-kernel/production"; + +const middleware = createProductionHttpAgentMiddleware({ + baseUrl: "https://agent-data.example.com", + apiKey: process.env.AGENTIC_DATA_API_KEY!, + principal: { + tenantId: "operations", + principalId: "incident-agent", + purpose: "incident-response", + }, +}); + +const session = middleware.beginRun({ + runId: "run:checkout-1001", +}); +``` + +The adapter sends the API key only in the HTTP authorization header and sends +the purpose in `X-Agent-Purpose`. It rejects plaintext non-loopback HTTP and +redirects. Responses are streamed through a byte limit, requests have a +deadline, and successful responses must match the requested protocol, +operation, principal, result hash, and receipt. Production TLS and CA trust +follow the host's Fetch API configuration. + +HTTP controls: + +```ts +createProductionHttpAgentMiddleware({ + // ... + requestTimeoutMs: 30_000, + maxResponseBytes: 10_000_000, +}); +``` + +Run the production example after configuring the normal production +environment: + +```powershell +npm run example:production-agent +``` + +Optional environment variables: + +```text +AGENTIC_DATA_QUERY +AGENTIC_DATA_RUN_ID +AGENTIC_DATA_TASK_ID +AGENTIC_DATA_CONVERSATION_ID +AGENTIC_DATA_INSTANCE_ID +``` + +## Direct MCP + +If the model host already supports MCP, it can use the existing embedded or +production MCP process directly. MCP is the model-visible transport; the +middleware API is the host-side lifecycle layer. + +Use direct MCP when the host already handles context assembly and durable turn +recording. Use the middleware when those behaviors should be consistent across +model vendors and agent frameworks. + +## External effects + +The agent must not treat an ordinary model tool call as proof that an external +operation completed. + +For high-impact actions: + +1. the model requests `request_effect` or `request_payment`; +2. the kernel commits the authorized effect intent and receipt; +3. the effect worker performs the external call; +4. an ambiguous outcome becomes `unknown` or `reconciling`; +5. a later agent turn reads `list_effects`; +6. the worker reconciles provider state using the original idempotency key; +7. the agent advances its workflow only after reading the durable outcome. + +This keeps provider retries, process restarts, and model retries from creating +duplicate external actions. + +## Host responsibilities + +The middleware intentionally does not: + +- invoke a language model; +- decide which model provider to use; +- persist hidden model reasoning; +- automatically trust model-generated assertions; +- define an application's workflow transition policy; +- schedule tenant timer sweeps; +- run effect workers; +- replace edge rate limiting or secret management. + +The host owns the model loop and decides which generated content becomes +durable. Agentic Data Kernel supplies the stateful, constrained, explainable +data plane beneath that loop. diff --git a/docs/API.md b/docs/API.md index 82b42ca..c412b36 100644 --- a/docs/API.md +++ b/docs/API.md @@ -12,6 +12,31 @@ | HTTP | PostgreSQL | `POST /v1/execute` | | MCP | Development | `agentic-data-kernel mcp` | | MCP | PostgreSQL | `agentic-data-prod mcp` | +| Agent middleware | Development | `createEmbeddedAgentMiddleware` | +| Agent middleware | PostgreSQL in-process | `createProductionAgentMiddleware` | +| Agent middleware | PostgreSQL HTTP | `createProductionHttpAgentMiddleware` | + +## Agent middleware + +The framework-neutral middleware binds a host-supplied principal, compiles +bounded durable context, returns model-facing JSON Schema tools, translates +tool calls into Agent Intent 1.0 envelopes, and records approved turns as +immutable artifacts. + +Core lifecycle: + +```text +create middleware + -> beginRun + -> prepareModelInput + -> model invocation owned by host + -> invokeTool + -> recordTurn +``` + +The model never supplies tenant, principal, purpose, or API-key fields. See +[Agent Middleware](AGENT_MIDDLEWARE.md) for embedded, in-process production, +remote HTTP, and direct MCP integration patterns. ## Intent envelope diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index dc1f2ee..f2a7513 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -17,9 +17,11 @@ Pin an exact version in applications and production deployments. | Integration | File | Command | | --- | --- | --- | +| Embedded agent middleware | `examples/integrations/agent-middleware.ts` | `npm run example:agent` | | Local TypeScript library | `examples/integrations/local-library.ts` | `npm run example:library` | | MCP client | `examples/integrations/mcp-client.ts` | `npm run example:mcp` | | Authenticated production HTTP | `examples/integrations/production-http.ts` | `npm run example:production-http` | +| Production HTTP agent middleware | `examples/integrations/production-agent-middleware.ts` | `npm run example:production-agent` | | Production retail workflow | `examples/integrations/production-retail.ts` | `npm run example:production-retail` | | Flagship SRE scenario | `examples/integrations/sre-scenario.ts` | `npm run example:sre` | | Embedding provider | `examples/integrations/embedding-provider.ts` | `npm run example:embedding` | @@ -75,6 +77,30 @@ const result = executeIntent(kernel, { The SQLite profile is intended for local and single-process use. +## Agent middleware + +The embedded middleware example demonstrates the full host lifecycle: + +1. bind a principal and begin an agent run; +2. expose framework-neutral JSON Schema tools; +3. compile bounded knowledge and resolution context; +4. execute model-style tool calls through Agent Intent; +5. store approved turn input, output, tool results, and receipt references. + +```powershell +npm run example:agent +``` + +The production HTTP adapter uses the same lifecycle while binding the API key +outside model-visible arguments: + +```powershell +npm run example:production-agent +``` + +See [Agent Middleware](AGENT_MIDDLEWARE.md) for the complete architecture, +security boundaries, and model-provider mapping. + ## MCP client An MCP client can launch the published embedded server directly: diff --git a/examples/integrations/agent-middleware.ts b/examples/integrations/agent-middleware.ts new file mode 100644 index 0000000..e988bcc --- /dev/null +++ b/examples/integrations/agent-middleware.ts @@ -0,0 +1,106 @@ +import { rmSync } from "node:fs"; +import { + AgenticKernel, + SqliteStore, + createEmbeddedAgentMiddleware, +} from "agentic-data-kernel"; + +const databasePath = ".data/integration-agent-middleware.db"; +for (const path of [ + databasePath, + `${databasePath}-shm`, + `${databasePath}-wal`, +]) { + rmSync(path, { force: true }); +} + +const store = new SqliteStore(databasePath); +const kernel = new AgenticKernel(store); +const session = createEmbeddedAgentMiddleware(kernel, { + tenantId: "operations", + principalId: "incident-agent", + purpose: "incident-response", +}).beginRun({ + runId: "run:checkout-1001", + taskId: "incident:checkout-1001", + conversationId: "conversation:checkout-1001", +}); + +try { + await session.invokeTool({ + callId: "create-service", + name: "execute_operation", + arguments: { + idempotencyKey: "create-service", + operation: { + op: "put_entity", + entity: { + entityId: "service:checkout", + entityType: "service", + canonicalName: "Checkout API", + }, + }, + }, + }); + await session.invokeTool({ + callId: "record-error-rate", + name: "execute_operation", + arguments: { + idempotencyKey: "record-error-rate", + operation: { + op: "assert", + assertion: { + assertionId: "assertion:checkout-error-rate", + subjectEntityId: "service:checkout", + predicate: "error_rate", + object: { type: "number", value: 0.42 }, + kind: "observation", + }, + }, + }, + }); + + const modelInput = await session.prepareModelInput({ + query: "checkout error rate", + resolutions: [ + { + subjectEntityId: "service:checkout", + predicate: "error_rate", + policy: "latest", + }, + ], + }); + + const modelOutput = { + conclusion: "The checkout error rate is elevated.", + evidenceReceiptIds: modelInput.context.includedReceiptIds, + }; + const turn = await session.recordTurn({ + turnId: "turn-1", + input: { role: "user", content: "Investigate checkout" }, + output: modelOutput, + contextReceiptIds: modelInput.context.includedReceiptIds, + }); + + console.log( + JSON.stringify( + { + modelTools: modelInput.tools.map((tool) => tool.name), + modelContext: JSON.parse(modelInput.context.modelContext), + turnArtifactId: turn.artifactId, + turnReceiptId: turn.receipt.receiptId, + }, + null, + 2, + ), + ); +} finally { + store.close(); + for (const path of [ + databasePath, + `${databasePath}-shm`, + `${databasePath}-wal`, + ]) { + rmSync(path, { force: true }); + } +} diff --git a/examples/integrations/production-agent-middleware.ts b/examples/integrations/production-agent-middleware.ts new file mode 100644 index 0000000..d3580b5 --- /dev/null +++ b/examples/integrations/production-agent-middleware.ts @@ -0,0 +1,48 @@ +import { createProductionHttpAgentMiddleware } from "agentic-data-kernel/production"; + +const workflowInstanceId = process.env.AGENTIC_DATA_INSTANCE_ID; +const session = createProductionHttpAgentMiddleware({ + baseUrl: required("AGENTIC_DATA_BASE_URL"), + apiKey: required("AGENTIC_DATA_API_KEY"), + principal: { + tenantId: required("AGENTIC_DATA_TENANT_ID"), + principalId: required("AGENTIC_DATA_PRINCIPAL_ID"), + purpose: required("AGENTIC_DATA_PURPOSE"), + }, +}).beginRun({ + runId: process.env.AGENTIC_DATA_RUN_ID ?? `run:${Date.now()}`, + taskId: process.env.AGENTIC_DATA_TASK_ID, + conversationId: process.env.AGENTIC_DATA_CONVERSATION_ID, +}); + +const modelInput = await session.prepareModelInput({ + query: process.env.AGENTIC_DATA_QUERY ?? "current operational context", + ...(workflowInstanceId + ? { + workflow: { instanceId: workflowInstanceId }, + effects: { instanceId: workflowInstanceId, limit: 10 }, + } + : {}), +}); + +console.log( + JSON.stringify( + { + tools: modelInput.tools, + context: JSON.parse(modelInput.context.modelContext), + includedReceiptIds: modelInput.context.includedReceiptIds, + partialReceiptIds: modelInput.context.partialReceiptIds, + omittedReceiptIds: modelInput.context.omittedReceiptIds, + }, + null, + 2, + ), +); + +function required(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} diff --git a/package-lock.json b/package-lock.json index 837ff89..954ce50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "agentic-data-prod": "dist/production/cli.js" }, "devDependencies": { + "ajv": "^8.20.0", "semver": "^7.8.5", "tsx": "^4.23.13", "typescript": "^7.0.2" diff --git a/package.json b/package.json index 2052200..93e6fdd 100644 --- a/package.json +++ b/package.json @@ -89,10 +89,12 @@ "release:check": "npm run check && npm test && npm run test:package && npm run test:backup-manifest", "deployment:check": "pwsh -NoProfile -File scripts/validate-deployments.ps1", "example": "node --no-warnings dist/cli.js example --db .data/example.db", - "example:all": "npm run example && npm run example:library && npm run example:mcp", + "example:all": "npm run example && npm run example:library && npm run example:agent && npm run example:mcp", + "example:agent": "node --no-warnings dist/examples/agent-middleware.js", "example:library": "node --no-warnings dist/examples/local-library.js", "example:mcp": "node --no-warnings dist/examples/mcp-client.js", "example:production-http": "node dist/examples/production-http.js", + "example:production-agent": "node dist/examples/production-agent-middleware.js", "example:production-retail": "node dist/examples/production-retail.js", "example:sre": "node --env-file=.env dist/examples/sre-scenario.js", "example:embedding": "node dist/examples/embedding-provider.js", @@ -121,6 +123,7 @@ "zod": "^4.5.4" }, "devDependencies": { + "ajv": "^8.20.0", "semver": "^7.8.5", "tsx": "^4.23.13", "typescript": "^7.0.2" diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index 3b67220..3d9446d 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -35,11 +35,17 @@ try { for (const requiredPath of [ "dist/index.js", "dist/index.d.ts", + "dist/agent.js", + "dist/agent.d.ts", "dist/production/index.js", "dist/production/index.d.ts", + "dist/production/agent.js", + "dist/production/agent.d.ts", "dist/production/bootstrap.js", "dist/production/bootstrap.d.ts", "dist/examples/sre-scenario.js", + "dist/examples/agent-middleware.js", + "dist/examples/production-agent-middleware.js", "migrations/postgres/001_core.sql", "migrations/postgres/002_embedding_space.sql", "migrations/postgres/003_generic_agency.sql", @@ -52,6 +58,7 @@ try { "deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml", "scripts/validate-deployments.ps1", "scripts/backup-common.ps1", + "docs/AGENT_MIDDLEWARE.md", "README.md", "LICENSE", ]) { @@ -111,15 +118,20 @@ try { writeFileSync( smokeModule, `import { + AgentDataMiddleware, AgenticKernel, KNOWLEDGE_OPERATION_NAMES, KnowledgeLayer, PACKAGE_VERSION, SqliteStore, + createEmbeddedAgentMiddleware, formatTraceExplanation, } from "agentic-data-kernel"; import { + AgentDataHttpError, bootstrapRuntimeRole, + createProductionAgentMiddleware, + createProductionHttpAgentMiddleware, OpenAiCompatibleEmbeddingProvider, postgresMigrationDirectory, } from "agentic-data-kernel/production"; @@ -141,6 +153,29 @@ try { if (typeof formatTraceExplanation !== "function") { throw new Error("Trace formatter export is unavailable"); } + const middleware = createEmbeddedAgentMiddleware(kernel, { + tenantId: "package-smoke", + principalId: "package-smoke", + purpose: "test", + }); + if (!(middleware instanceof AgentDataMiddleware)) { + throw new Error("Agent middleware export is unavailable"); + } + const session = middleware.beginRun({ runId: "package-smoke" }); + if ( + !session + .modelTools() + .some((tool) => tool.name === "execute_operation") + ) { + throw new Error("Agent middleware model tools are unavailable"); + } + if ( + typeof createProductionAgentMiddleware !== "function" || + typeof createProductionHttpAgentMiddleware !== "function" || + typeof AgentDataHttpError !== "function" + ) { + throw new Error("Production agent middleware exports are unavailable"); + } if ( !(kernel.knowledge instanceof KnowledgeLayer) || !KNOWLEDGE_OPERATION_NAMES.includes("assert") @@ -171,6 +206,9 @@ try { writeFileSync( typeSmokeModule, `import { + type AgentContextBundle, + type AgentDataMiddlewareConfig, + type AgentDataSession, AgenticKernel, type AgentIntentVersion, type KnowledgeOperationName, @@ -180,6 +218,7 @@ try { formatTraceExplanation, } from "agentic-data-kernel"; import { + type ProductionHttpAgentMiddlewareConfig, bootstrapRuntimeRole, type EmbeddingSpace, ProductionDatabase, @@ -201,6 +240,10 @@ const embeddingSpace: EmbeddingSpace = { version: "1", dimensions: 768, }; +const middlewareConfig: AgentDataMiddlewareConfig | null = null; +const contextBundle: AgentContextBundle | null = null; +const agentSession: AgentDataSession | null = null; +const httpMiddlewareConfig: ProductionHttpAgentMiddlewareConfig | null = null; void kernel; void knowledgeLayer; void knowledgeOperation; @@ -211,6 +254,10 @@ void databaseType; void bootstrapType; void migrationPath; void embeddingSpace; +void middlewareConfig; +void contextBundle; +void agentSession; +void httpMiddlewareConfig; store.close(); `, ); diff --git a/src/agent.ts b/src/agent.ts new file mode 100644 index 0000000..d0c837a --- /dev/null +++ b/src/agent.ts @@ -0,0 +1,1203 @@ +import { randomUUID } from "node:crypto"; +import * as z from "zod/v4"; +import { + agentOperationSchema, + executeIntent, + lineageEndpointSchema, + type AgentOperation, + type IntentEnvelope, + type IntentExecutionResult, +} from "./ir.js"; +import { AgenticKernel, KernelError } from "./kernel.js"; +import type { + ExecutionReceipt, + JsonValue, + LineageEndpoint, + PrincipalContext, +} from "./types.js"; +import { sha256, stableStringify } from "./util.js"; +import { AGENT_INTENT_VERSION } from "./version.js"; + +const nonEmptyString = z.string().trim().min(1); +const middlewareIdentifier = nonEmptyString + .max(512) + .regex( + /^[^\u0000-\u001f\u007f]+$/, + "Identifiers must not contain control characters", + ); +const safeModelOperationNames = [ + "put_entity", + "assert", + "resolve", + "search", + "create_workflow", + "advance_workflow", + "request_effect", + "add_lineage", + "explain", + "reserve_inventory", + "request_payment", + "get_machine", + "list_effects", +] as const satisfies readonly AgentOperation["op"][]; + +export const DEFAULT_MODEL_OPERATION_NAMES: + readonly AgentOperation["op"][] = Object.freeze([ + ...safeModelOperationNames, + ]); + +const agentToolNames = [ + "search_knowledge", + "resolve_claims", + "get_machine", + "list_effects", + "explain_trace", + "execute_operation", +] as const; +const agentToolNameSchema = z.enum(agentToolNames); + +const searchArgumentsSchema = z + .object({ + text: nonEmptyString, + predicate: nonEmptyString.optional(), + kind: z + .enum([ + "observation", + "reported_fact", + "inference", + "prediction", + "hypothesis", + "decision", + "directive", + "experience", + ]) + .optional(), + perspective: nonEmptyString.optional(), + relatedToEntityId: nonEmptyString.optional(), + maxGraphDepth: z.number().int().min(0).max(8).optional(), + validAt: z.iso.datetime({ offset: true }).optional(), + systemAt: z.iso.datetime({ offset: true }).optional(), + limit: z.number().int().min(1).max(100).optional(), + }) + .strict(); + +const resolveArgumentsSchema = z + .object({ + subjectEntityId: nonEmptyString, + predicate: nonEmptyString, + policy: z + .enum(["none", "latest", "highest_authority"]) + .default("none"), + perspective: nonEmptyString.optional(), + validAt: z.iso.datetime({ offset: true }).optional(), + systemAt: z.iso.datetime({ offset: true }).optional(), + }) + .strict(); + +const machineArgumentsSchema = z + .object({ + instanceId: nonEmptyString, + }) + .strict(); + +const effectsArgumentsSchema = z + .object({ + instanceId: nonEmptyString.optional(), + afterEffectId: nonEmptyString.optional(), + limit: z.number().int().min(1).max(100).optional(), + }) + .strict(); + +const explainArgumentsSchema = z + .object({ + target: lineageEndpointSchema, + maxDepth: z.number().int().min(0).max(8).optional(), + }) + .strict(); + +const executeArgumentsSchema = z + .object({ + operation: agentOperationSchema, + idempotencyKey: middlewareIdentifier.optional(), + }) + .strict(); + +const contextRequestSchema = z + .object({ + query: nonEmptyString.optional(), + search: z + .union([ + z.literal(false), + searchArgumentsSchema.partial({ text: true }), + ]) + .optional(), + resolutions: z.array(resolveArgumentsSchema).max(20).optional(), + workflow: machineArgumentsSchema.optional(), + effects: effectsArgumentsSchema.optional(), + traces: z.array(explainArgumentsSchema).max(8).optional(), + maxCharacters: z.number().int().min(1_000).max(100_000).optional(), + }) + .strict(); + +const recordedToolCallSchema = z + .object({ + name: nonEmptyString, + arguments: z.json(), + result: z.json().optional(), + receiptId: nonEmptyString.optional(), + error: nonEmptyString.optional(), + }) + .strict(); + +const turnRecordSchema = z + .object({ + turnId: middlewareIdentifier.max(256), + input: z.json().optional(), + output: z.json().optional(), + contextReceiptIds: z.array(nonEmptyString).max(1_000).optional(), + toolCalls: z.array(recordedToolCallSchema).max(100).optional(), + metadata: z.json().optional(), + observedAt: z.iso.datetime({ offset: true }).optional(), + sensitivity: nonEmptyString.optional(), + retentionPolicy: nonEmptyString.optional(), + }) + .strict(); + +const runInputSchema = z + .object({ + runId: middlewareIdentifier.max(256), + taskId: middlewareIdentifier.max(256).optional(), + conversationId: middlewareIdentifier.max(256).optional(), + metadata: z.json().optional(), + durableMetadata: z.json().optional(), + }) + .strict(); + +const principalSchema = z + .object({ + tenantId: middlewareIdentifier, + principalId: middlewareIdentifier, + purpose: middlewareIdentifier, + }) + .strict(); + +export type AgentToolName = (typeof agentToolNames)[number]; + +export interface AgentToolDefinition { + name: AgentToolName; + title: string; + description: string; + inputSchema: Record; + readOnly: boolean; +} + +export interface AgentToolCall { + callId?: string; + name: AgentToolName; + arguments: unknown; +} + +export interface AgentToolResult { + callId: string; + name: AgentToolName; + result: JsonValue; + receipt: ExecutionReceipt; + idempotentReplay: boolean; + modelContent: string; +} + +export interface AgentRunInput { + runId: string; + taskId?: string; + conversationId?: string; + metadata?: JsonValue; + durableMetadata?: JsonValue; +} + +export interface AgentContextSearch { + text?: string; + predicate?: string; + kind?: + | "observation" + | "reported_fact" + | "inference" + | "prediction" + | "hypothesis" + | "decision" + | "directive" + | "experience"; + perspective?: string; + relatedToEntityId?: string; + maxGraphDepth?: number; + validAt?: string; + systemAt?: string; + limit?: number; +} + +export interface AgentContextResolution { + subjectEntityId: string; + predicate: string; + policy?: "none" | "latest" | "highest_authority"; + perspective?: string; + validAt?: string; + systemAt?: string; +} + +export interface AgentContextTrace { + target: LineageEndpoint; + maxDepth?: number; +} + +export interface AgentContextRequest { + query?: string; + search?: AgentContextSearch | false; + resolutions?: AgentContextResolution[]; + workflow?: { instanceId: string }; + effects?: { + instanceId?: string; + afterEffectId?: string; + limit?: number; + }; + traces?: AgentContextTrace[]; + maxCharacters?: number; +} + +export interface AgentContextSection { + type: "search" | "resolution" | "workflow" | "effects" | "trace"; + key: string; + result: JsonValue; + receiptId: string; +} + +export interface AgentContextBundle { + runId: string; + taskId?: string; + conversationId?: string; + generatedAt: string; + query?: string; + sections: AgentContextSection[]; + includedReceiptIds: string[]; + partialReceiptIds: string[]; + omittedReceiptIds: string[]; + modelContext: string; + truncated: boolean; +} + +export interface AgentModelInput { + context: AgentContextBundle; + tools: AgentToolDefinition[]; +} + +export interface AgentRecordedToolCall { + name: string; + arguments: JsonValue; + result?: JsonValue; + receiptId?: string; + error?: string; +} + +export interface AgentTurnRecordInput { + turnId: string; + input?: JsonValue; + output?: JsonValue; + toolCalls?: AgentRecordedToolCall[]; + contextReceiptIds?: string[]; + metadata?: JsonValue; + observedAt?: string; + sensitivity?: string; + retentionPolicy?: string; +} + +export interface AgentTurnRecord { + runId: string; + turnId: string; + artifactId: string; + contentHash: string; + receipt: ExecutionReceipt; + idempotentReplay: boolean; +} + +export type AgentIntentExecutor = ( + envelope: IntentEnvelope, +) => Promise; + +export interface AgentDataMiddlewareConfig { + principal: PrincipalContext; + execute: AgentIntentExecutor; + allowedOperations?: readonly AgentOperation["op"][]; + defaultSearchLimit?: number; + defaultEffectLimit?: number; + maxContextCharacters?: number; + maxTurnCharacters?: number; +} + +export interface AgentDataSession { + identity(): PrincipalContext; + runInfo(): AgentRunInput; + modelTools(): AgentToolDefinition[]; + prepareModelInput(request?: AgentContextRequest): Promise; + compileContext(request?: AgentContextRequest): Promise; + invokeTool(call: AgentToolCall): Promise; + execute( + operation: AgentOperation, + options?: { + requestId?: string; + idempotencyKey?: string; + }, + ): Promise; + recordTurn(input: AgentTurnRecordInput): Promise; +} + +interface NormalizedMiddlewareConfig { + principal: PrincipalContext; + execute: AgentIntentExecutor; + allowedOperations: ReadonlySet; + defaultSearchLimit: number; + defaultEffectLimit: number; + maxContextCharacters: number; + maxTurnCharacters: number; +} + +const toolDefinitions: readonly AgentToolDefinition[] = [ + { + name: "search_knowledge", + title: "Search durable knowledge", + description: + "Retrieve bounded, tenant-scoped assertions using semantic, lexical, temporal, epistemic, and graph filters.", + inputSchema: modelJsonSchema(searchArgumentsSchema), + readOnly: true, + }, + { + name: "resolve_claims", + title: "Resolve durable claims", + description: + "Read known, unknown, conflicted, or policy-selected assertions without hiding disagreement.", + inputSchema: optionalJsonSchemaProperty( + modelJsonSchema(resolveArgumentsSchema), + "policy", + ), + readOnly: true, + }, + { + name: "get_machine", + title: "Read durable workflow state", + description: + "Read the current state and revision of a durable workflow or retail order.", + inputSchema: modelJsonSchema(machineArgumentsSchema), + readOnly: true, + }, + { + name: "list_effects", + title: "Read durable effect state", + description: + "Inspect planned, dispatching, unknown, reconciling, succeeded, failed, or cancelled effects.", + inputSchema: modelJsonSchema(effectsArgumentsSchema), + readOnly: true, + }, + { + name: "explain_trace", + title: "Explain a durable causal trace", + description: + "Traverse bounded evidence, decision, policy, workflow, effect, reconciliation, and verification lineage.", + inputSchema: modelJsonSchema(explainArgumentsSchema), + readOnly: true, + }, + { + name: "execute_operation", + title: "Execute an Agent Intent operation", + description: + "Execute one allowed, typed Agent Intent operation. Identity is supplied by the host and cannot be overridden by the model.", + inputSchema: modelJsonSchema(executeArgumentsSchema), + readOnly: false, + }, +]; + +export class AgentDataMiddleware { + private readonly config: NormalizedMiddlewareConfig; + + public constructor(config: AgentDataMiddlewareConfig) { + const principal = parseInput(principalSchema, config.principal); + const allowedOperationNames = + config.allowedOperations ?? safeModelOperationNames; + const defaultOperationSet = new Set( + safeModelOperationNames, + ); + for (const operation of allowedOperationNames) { + if (!defaultOperationSet.has(operation)) { + throw new KernelError( + "invalid_input", + `Agent middleware cannot expose privileged operation ${operation}`, + ); + } + } + this.config = { + principal, + execute: config.execute, + allowedOperations: new Set(allowedOperationNames), + defaultSearchLimit: boundedInteger( + config.defaultSearchLimit ?? 10, + 1, + 20, + "defaultSearchLimit", + ), + defaultEffectLimit: boundedInteger( + config.defaultEffectLimit ?? 10, + 1, + 20, + "defaultEffectLimit", + ), + maxContextCharacters: boundedInteger( + config.maxContextCharacters ?? 24_000, + 1_000, + 100_000, + "maxContextCharacters", + ), + maxTurnCharacters: boundedInteger( + config.maxTurnCharacters ?? 1_000_000, + 1_000, + 10_000_000, + "maxTurnCharacters", + ), + }; + } + + public beginRun(input: AgentRunInput): AgentDataSession { + const run = parseInput(runInputSchema, input); + return new DefaultAgentDataSession(this.config, run); + } +} + +class DefaultAgentDataSession implements AgentDataSession { + private readonly principal: PrincipalContext; + private readonly run: AgentRunInput; + + public constructor( + private readonly config: NormalizedMiddlewareConfig, + run: AgentRunInput, + ) { + this.principal = { ...config.principal }; + this.run = { ...run }; + } + + public identity(): PrincipalContext { + return { ...this.principal }; + } + + public runInfo(): AgentRunInput { + return structuredClone(this.run); + } + + public modelTools(): AgentToolDefinition[] { + return toolDefinitions + .filter((definition) => { + const operation = toolOperation(definition.name); + return operation === null + ? this.config.allowedOperations.size > 0 + : this.config.allowedOperations.has(operation); + }) + .map((definition) => ({ + ...definition, + description: + definition.name === "execute_operation" + ? `${definition.description} Allowed operations: ${[ + ...this.config.allowedOperations, + ].join(", ")}.` + : definition.description, + inputSchema: structuredClone(definition.inputSchema), + ...(definition.name === "execute_operation" + ? { + inputSchema: restrictedExecuteSchema( + this.config.allowedOperations, + ), + } + : {}), + })); + } + + public async prepareModelInput( + request: AgentContextRequest = {}, + ): Promise { + return { + context: await this.compileContext(request), + tools: this.modelTools(), + }; + } + + public async compileContext( + request: AgentContextRequest = {}, + ): Promise { + const parsed = parseInput(contextRequestSchema, request); + const sections: AgentContextSection[] = []; + + const addSection = async ( + type: AgentContextSection["type"], + key: string, + operation: AgentOperation, + ): Promise => { + const execution = await this.execute(operation); + sections.push({ + type, + key, + result: execution.result, + receiptId: execution.receipt.receiptId, + }); + }; + + if (parsed.search !== false) { + const search = parsed.search ?? {}; + const text = search.text ?? parsed.query; + if (text) { + await addSection("search", text, { + op: "search", + ...search, + text, + limit: Math.min( + search.limit ?? this.config.defaultSearchLimit, + 20, + ), + }); + } else if (parsed.search !== undefined) { + throw new KernelError( + "invalid_input", + "Context search requires query or search.text", + ); + } + } + + for (const resolution of parsed.resolutions ?? []) { + await addSection( + "resolution", + `${resolution.subjectEntityId}:${resolution.predicate}`, + { op: "resolve", ...resolution }, + ); + } + + if (parsed.workflow) { + await addSection( + "workflow", + parsed.workflow.instanceId, + { op: "get_machine", ...parsed.workflow }, + ); + } + + if (parsed.effects) { + await addSection( + "effects", + parsed.effects.instanceId ?? "all", + { + op: "list_effects", + ...parsed.effects, + limit: Math.min( + parsed.effects.limit ?? this.config.defaultEffectLimit, + 20, + ), + }, + ); + } + + for (const trace of parsed.traces ?? []) { + await addSection( + "trace", + lineageKey(trace.target), + { op: "explain", ...trace }, + ); + } + + const generatedAt = new Date().toISOString(); + const payload = { + runId: this.run.runId, + ...(this.run.taskId ? { taskId: this.run.taskId } : {}), + ...(this.run.conversationId + ? { conversationId: this.run.conversationId } + : {}), + generatedAt, + ...(parsed.query ? { query: parsed.query } : {}), + sections: sections.map((section) => ({ + type: section.type, + key: section.key, + result: section.result, + receiptId: section.receiptId, + })), + }; + const bounded = boundedModelContext( + payload, + sections, + parsed.maxCharacters ?? this.config.maxContextCharacters, + ); + return { + runId: this.run.runId, + ...(this.run.taskId ? { taskId: this.run.taskId } : {}), + ...(this.run.conversationId + ? { conversationId: this.run.conversationId } + : {}), + generatedAt, + ...(parsed.query ? { query: parsed.query } : {}), + sections, + includedReceiptIds: bounded.includedReceiptIds, + partialReceiptIds: bounded.partialReceiptIds, + omittedReceiptIds: bounded.omittedReceiptIds, + modelContext: bounded.text, + truncated: bounded.truncated, + }; + } + + public async invokeTool(call: AgentToolCall): Promise { + const name = parseInput(agentToolNameSchema, call.name); + const callId = parseInput( + middlewareIdentifier, + call.callId ?? randomUUID(), + ); + let operation: AgentOperation; + let idempotencyKey: string | undefined; + + switch (name) { + case "search_knowledge": + operation = { + op: "search", + ...parseInput(searchArgumentsSchema, call.arguments), + }; + break; + case "resolve_claims": + operation = { + op: "resolve", + ...parseInput(resolveArgumentsSchema, call.arguments), + }; + break; + case "get_machine": + operation = { + op: "get_machine", + ...parseInput(machineArgumentsSchema, call.arguments), + }; + break; + case "list_effects": + operation = { + op: "list_effects", + ...parseInput(effectsArgumentsSchema, call.arguments), + }; + break; + case "explain_trace": + operation = { + op: "explain", + ...parseInput(explainArgumentsSchema, call.arguments), + }; + break; + case "execute_operation": { + const parsed = parseInput( + executeArgumentsSchema, + call.arguments, + ); + operation = parsed.operation; + idempotencyKey = parsed.idempotencyKey; + break; + } + default: + return assertNever(name); + } + + if (!this.config.allowedOperations.has(operation.op)) { + throw new KernelError( + "unauthorized", + `Agent middleware does not expose ${operation.op}`, + ); + } + const logicalKey = idempotencyKey ?? callId; + const toolIdentity = sha256( + stableStringify([ + this.principal.tenantId, + this.principal.principalId, + this.run.runId, + callId, + ]), + ).slice(0, 32); + const execution = await this.dispatch(operation, { + requestId: `agent-tool:${toolIdentity}`, + idempotencyKey: this.scopedIdempotencyKey( + "tool-call", + logicalKey, + ), + }); + const modelPayload = { + result: execution.result, + receiptId: execution.receipt.receiptId, + idempotentReplay: execution.idempotentReplay, + }; + return { + callId, + name, + result: execution.result, + receipt: execution.receipt, + idempotentReplay: execution.idempotentReplay, + modelContent: JSON.stringify(modelPayload), + }; + } + + public async execute( + operation: AgentOperation, + options: { + requestId?: string; + idempotencyKey?: string; + } = {}, + ): Promise { + const parsedOperation = parseInput(agentOperationSchema, operation); + const requestId = options.requestId + ? parseInput(middlewareIdentifier, options.requestId) + : `agent:${randomUUID()}`; + const idempotencyKey = options.idempotencyKey + ? parseInput(middlewareIdentifier, options.idempotencyKey) + : undefined; + return this.dispatch(parsedOperation, { + requestId, + idempotencyKey: idempotencyKey + ? this.scopedIdempotencyKey( + "host-operation", + idempotencyKey, + ) + : undefined, + }); + } + + private async dispatch( + operation: AgentOperation, + options: { + requestId: string; + idempotencyKey?: string; + }, + ): Promise { + return this.config.execute({ + protocolVersion: AGENT_INTENT_VERSION, + requestId: options.requestId, + ...(options.idempotencyKey + ? { idempotencyKey: options.idempotencyKey } + : {}), + principal: this.principal, + operation, + }); + } + + public async recordTurn( + input: AgentTurnRecordInput, + ): Promise { + const turn = parseInput(turnRecordSchema, input); + const turnIdentity = sha256( + stableStringify([ + this.principal.tenantId, + this.principal.principalId, + this.run.runId, + turn.turnId, + ]), + ).slice(0, 32); + const artifactId = `agent-turn:${turnIdentity}`; + const content = stableStringify({ + schemaVersion: 1, + run: { + runId: this.run.runId, + ...(this.run.taskId ? { taskId: this.run.taskId } : {}), + ...(this.run.conversationId + ? { conversationId: this.run.conversationId } + : {}), + ...(this.run.durableMetadata !== undefined + ? { metadata: this.run.durableMetadata } + : {}), + }, + principal: this.principal, + turn, + }); + if (content.length > this.config.maxTurnCharacters) { + throw new KernelError( + "invalid_input", + `Agent turn exceeds ${this.config.maxTurnCharacters} characters`, + ); + } + const execution = await this.dispatch( + { + op: "put_artifact", + artifact: { + artifactId, + mediaType: "application/vnd.agentic-data.agent-turn+json", + content, + sourceIdentity: `agent:${this.principal.principalId}`, + ...(turn.observedAt ? { observedAt: turn.observedAt } : {}), + sensitivity: turn.sensitivity ?? "internal", + retentionPolicy: turn.retentionPolicy ?? "agent-run", + }, + }, + { + requestId: `agent-turn:${turnIdentity}`, + idempotencyKey: this.scopedIdempotencyKey( + "turn-record", + turn.turnId, + ), + }, + ); + return { + runId: this.run.runId, + turnId: turn.turnId, + artifactId: resultString(execution.result, "artifactId"), + contentHash: resultString(execution.result, "contentHash"), + receipt: execution.receipt, + idempotentReplay: execution.idempotentReplay, + }; + } + + private scopedIdempotencyKey( + domain: "host-operation" | "tool-call" | "turn-record", + logicalKey: string, + ): string { + return `agent-${domain}:${sha256( + stableStringify([ + this.principal.tenantId, + this.principal.principalId, + this.run.runId, + logicalKey, + ]), + ).slice(0, 48)}`; + } +} + +export function createAgentDataMiddleware( + config: AgentDataMiddlewareConfig, +): AgentDataMiddleware { + return new AgentDataMiddleware(config); +} + +export function createEmbeddedAgentMiddleware( + kernel: AgenticKernel, + principal: PrincipalContext, + options: Omit< + AgentDataMiddlewareConfig, + "principal" | "execute" + > = {}, +): AgentDataMiddleware { + return new AgentDataMiddleware({ + ...options, + principal, + execute: async (envelope) => executeIntent(kernel, envelope), + }); +} + +function boundedInteger( + value: number, + minimum: number, + maximum: number, + field: string, +): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new KernelError( + "invalid_input", + `${field} must be an integer from ${minimum} through ${maximum}`, + ); + } + return value; +} + +function boundedModelContext( + metadata: Record, + sections: AgentContextSection[], + maximumCharacters: number, +): { + text: string; + truncated: boolean; + includedReceiptIds: string[]; + partialReceiptIds: string[]; + omittedReceiptIds: string[]; +} { + const modelSections: Array<{ + type: AgentContextSection["type"]; + key: string; + receiptId: string; + status: "complete" | "truncated" | "omitted"; + result?: JsonValue; + }> = sections.map((section) => ({ + type: section.type, + key: section.key, + receiptId: section.receiptId, + status: "omitted", + })); + const payload = { ...metadata, sections: modelSections }; + let serialized = JSON.stringify(payload, null, 2); + if (serialized.length > maximumCharacters) { + throw new KernelError( + "invalid_input", + "Context section metadata exceeds maxCharacters; request fewer sections", + ); + } + + if (sections.length > 0) { + const previewBudget = Math.max( + 0, + Math.floor( + (maximumCharacters - serialized.length) / sections.length, + ) - 160, + ); + for (let index = 0; index < sections.length; index += 1) { + const section = sections[index]; + const modelSection = modelSections[index]; + if (!section || !modelSection || previewBudget === 0) { + continue; + } + const result = JSON.stringify(section.result); + modelSection.status = "truncated"; + modelSection.result = { + truncated: true, + originalCharacters: result.length, + preview: result.slice(0, previewBudget), + }; + } + serialized = JSON.stringify(payload, null, 2); + while (serialized.length > maximumCharacters) { + const candidate = [...modelSections] + .reverse() + .find( + (section) => + section.status === "truncated" && + section.result !== undefined, + ); + if (!candidate) { + break; + } + candidate.status = "omitted"; + delete candidate.result; + serialized = JSON.stringify(payload, null, 2); + } + } + + for (let index = 0; index < sections.length; index += 1) { + const section = sections[index]; + const modelSection = modelSections[index]; + if (!section || !modelSection) { + continue; + } + const priorStatus = modelSection.status; + const priorResult = modelSection.result; + modelSection.status = "complete"; + modelSection.result = section.result; + const candidate = JSON.stringify(payload, null, 2); + if (candidate.length <= maximumCharacters) { + serialized = candidate; + continue; + } + modelSection.status = priorStatus; + modelSection.result = priorResult; + } + + const includedReceiptIds = modelSections + .filter((section) => section.status === "complete") + .map((section) => section.receiptId); + const partialReceiptIds = modelSections + .filter((section) => section.status === "truncated") + .map((section) => section.receiptId); + const omittedReceiptIds = modelSections + .filter((section) => section.status === "omitted") + .map((section) => section.receiptId); + return { + text: serialized, + truncated: + partialReceiptIds.length > 0 || omittedReceiptIds.length > 0, + includedReceiptIds, + partialReceiptIds, + omittedReceiptIds, + }; +} + +function resultString(value: JsonValue, field: string): string { + if ( + value === null || + Array.isArray(value) || + typeof value !== "object" || + typeof value[field] !== "string" + ) { + throw new Error(`Agent middleware expected result field ${field}`); + } + return value[field]; +} + +function lineageKey(target: LineageEndpoint): string { + switch (target.type) { + case "artifact": + return `artifact:${target.artifactId}`; + case "assertion": + return `assertion:${target.assertionId}`; + case "workflow_revision": + return `workflow:${target.instanceId}@${target.revision}`; + case "effect": + return `effect:${target.effectId}`; + default: + return assertNever(target); + } +} + +function toolOperation( + name: AgentToolName, +): AgentOperation["op"] | null { + switch (name) { + case "search_knowledge": + return "search"; + case "resolve_claims": + return "resolve"; + case "get_machine": + return "get_machine"; + case "list_effects": + return "list_effects"; + case "explain_trace": + return "explain"; + case "execute_operation": + return null; + default: + return assertNever(name); + } +} + +function restrictedExecuteSchema( + allowedOperations: ReadonlySet, +): Record { + const schema = structuredClone( + modelJsonSchema(executeArgumentsSchema), + ); + const properties = objectValue(schema, "properties"); + const operation = objectValue(properties, "operation"); + const variants = operation.oneOf; + if (!Array.isArray(variants)) { + throw new Error("Agent operation JSON Schema did not contain oneOf"); + } + operation.oneOf = variants.filter((variant) => { + if (!isObject(variant)) { + return false; + } + const variantProperties = objectValue(variant, "properties"); + const operationName = objectValue(variantProperties, "op").const; + const included = + typeof operationName === "string" && + allowedOperations.has(operationName as AgentOperation["op"]); + if (included) { + normalizeOperationSchema(variant, operationName); + } + return included; + }); + return schema; +} + +function normalizeOperationSchema( + variant: Record, + operationName: string, +): void { + if (operationName === "resolve") { + removeRequiredProperty(variant, "policy"); + } + if (operationName === "request_payment") { + const properties = objectValue(variant, "properties"); + const amount = objectValue(properties, "amount"); + amount.not = { enum: zeroDecimalStrings }; + } + if (operationName === "request_effect") { + const allOf = Array.isArray(variant.allOf) + ? [...variant.allOf] + : []; + allOf.push({ + if: { + required: ["budgetAmount"], + properties: { + budgetAmount: { + not: { enum: zeroDecimalStrings }, + }, + }, + }, + then: { + required: ["currency"], + }, + }); + variant.allOf = allOf; + } +} + +const zeroDecimalStrings = [ + "0", + "0.0", + "0.00", + "0.000", + "0.0000", +] as const; + +function optionalJsonSchemaProperty( + schema: Record, + property: string, +): Record { + const copy = structuredClone(schema); + removeRequiredProperty(copy, property); + return copy; +} + +function modelJsonSchema( + schema: z.ZodType, +): Record { + const jsonSchema = z.toJSONSchema(schema); + enforceNonWhitespaceStrings(jsonSchema); + return jsonSchema; +} + +function enforceNonWhitespaceStrings(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) { + enforceNonWhitespaceStrings(item); + } + return; + } + if (!isObject(value)) { + return; + } + if ( + value.type === "string" && + typeof value.minLength === "number" && + value.minLength >= 1 && + value.pattern === undefined + ) { + value.pattern = "\\S"; + } + for (const child of Object.values(value)) { + enforceNonWhitespaceStrings(child); + } +} + +function removeRequiredProperty( + schema: Record, + property: string, +): void { + if (!Array.isArray(schema.required)) { + return; + } + schema.required = schema.required.filter( + (candidate) => candidate !== property, + ); +} + +function objectValue( + source: Record, + field: string, +): Record { + const value = source[field]; + if (!isObject(value)) { + throw new Error(`Agent JSON Schema did not contain ${field}`); + } + return value; +} + +function isObject(value: unknown): value is Record { + return value !== null && !Array.isArray(value) && typeof value === "object"; +} + +function parseInput( + schema: z.ZodType, + input: unknown, +): T { + const parsed = schema.safeParse(input); + if (!parsed.success) { + throw new KernelError( + "invalid_input", + z.prettifyError(parsed.error), + ); + } + return parsed.data; +} + +function assertNever(value: never): never { + throw new Error(`Unsupported agent middleware value ${JSON.stringify(value)}`); +} diff --git a/src/index.ts b/src/index.ts index 50049d8..863b43a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,30 @@ export { AgenticKernel, KernelError } from "./kernel.js"; +export { + AgentDataMiddleware, + createAgentDataMiddleware, + createEmbeddedAgentMiddleware, + DEFAULT_MODEL_OPERATION_NAMES, +} from "./agent.js"; +export type { + AgentContextBundle, + AgentContextRequest, + AgentContextResolution, + AgentContextSearch, + AgentContextSection, + AgentContextTrace, + AgentDataMiddlewareConfig, + AgentDataSession, + AgentIntentExecutor, + AgentModelInput, + AgentRecordedToolCall, + AgentRunInput, + AgentToolCall, + AgentToolDefinition, + AgentToolName, + AgentToolResult, + AgentTurnRecord, + AgentTurnRecordInput, +} from "./agent.js"; export { agentOperationSchema, executeIntent, diff --git a/src/production/agent.ts b/src/production/agent.ts new file mode 100644 index 0000000..400926a --- /dev/null +++ b/src/production/agent.ts @@ -0,0 +1,307 @@ +import * as z from "zod/v4"; +import { isIP } from "node:net"; +import { + AgentDataMiddleware, + type AgentDataMiddlewareConfig, +} from "../agent.js"; +import type { IntentExecutionResult } from "../ir.js"; +import type { PrincipalContext } from "../types.js"; +import { sha256, stableStringify } from "../util.js"; +import { SUPPORTED_AGENT_INTENT_VERSIONS } from "../version.js"; +import type { AuthenticatedPrincipal } from "./auth.js"; +import type { ProductionKernel } from "./kernel.js"; + +const receiptSchema = z + .object({ + tenantId: z.string().min(1), + receiptId: z.string().min(1), + requestId: z.string().min(1), + principalId: z.string().min(1), + purpose: z.string().min(1), + operation: z.string().min(1), + snapshotTime: z.iso.datetime({ offset: true }), + evidenceManifest: z.json(), + resultHash: z.string().regex(/^[a-f0-9]{64}$/), + result: z.json(), + createdAt: z.iso.datetime({ offset: true }), + }) + .strict(); + +const executionResultSchema = z + .object({ + protocolVersion: z.enum(SUPPORTED_AGENT_INTENT_VERSIONS), + requestId: z.string().min(1), + status: z.literal("ok"), + operation: z.string().min(1), + result: z.json(), + receipt: receiptSchema, + idempotentReplay: z.boolean(), + }) + .passthrough(); + +const errorResponseSchema = z + .object({ + error: z + .object({ + code: z.string(), + message: z.string(), + }) + .strict(), + requestId: z.string().optional(), + }) + .strict(); + +type MiddlewareOptions = Omit< + AgentDataMiddlewareConfig, + "principal" | "execute" +>; + +export interface ProductionHttpAgentMiddlewareConfig + extends MiddlewareOptions { + baseUrl: string; + apiKey: string; + principal: PrincipalContext; + fetch?: typeof globalThis.fetch; + requestTimeoutMs?: number; + maxResponseBytes?: number; +} + +export class AgentDataHttpError extends Error { + public constructor( + public readonly status: number, + public readonly code: string, + message: string, + public readonly requestId?: string, + ) { + super(message); + this.name = "AgentDataHttpError"; + } +} + +export function createProductionAgentMiddleware( + kernel: ProductionKernel, + principal: AuthenticatedPrincipal, + options: MiddlewareOptions = {}, +): AgentDataMiddleware { + return new AgentDataMiddleware({ + ...options, + principal: { + tenantId: principal.tenantId, + principalId: principal.principalId, + purpose: principal.purpose, + }, + execute: (envelope) => kernel.execute(principal, envelope), + }); +} + +export function createProductionHttpAgentMiddleware( + config: ProductionHttpAgentMiddlewareConfig, +): AgentDataMiddleware { + const endpoint = executionEndpoint(config.baseUrl); + const apiKey = z.string().min(1).parse(config.apiKey); + const requestTimeoutMs = boundedInteger( + config.requestTimeoutMs ?? 30_000, + 1_000, + 120_000, + "requestTimeoutMs", + ); + const maxResponseBytes = boundedInteger( + config.maxResponseBytes ?? 10_000_000, + 1_024, + 10_000_000, + "maxResponseBytes", + ); + const fetchImplementation = config.fetch ?? globalThis.fetch; + if (typeof fetchImplementation !== "function") { + throw new Error("A Fetch API implementation is required"); + } + const { + baseUrl: _baseUrl, + apiKey: _apiKey, + principal, + fetch: _fetch, + requestTimeoutMs: _requestTimeoutMs, + maxResponseBytes: _maxResponseBytes, + ...options + } = config; + return new AgentDataMiddleware({ + ...options, + principal, + execute: async (envelope) => { + let response: Response; + let text: string; + try { + response = await fetchImplementation(endpoint, { + method: "POST", + headers: { + authorization: ["Bearer", apiKey].join(" "), + "content-type": "application/json", + "x-agent-purpose": principal.purpose, + }, + body: JSON.stringify(envelope), + redirect: "error", + signal: AbortSignal.timeout(requestTimeoutMs), + }); + text = await readLimitedBody(response, maxResponseBytes); + } catch (error) { + if (error instanceof AgentDataHttpError) { + throw error; + } + const timeout = + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError"); + throw new AgentDataHttpError( + timeout ? 504 : 0, + timeout ? "timeout" : "network_error", + timeout + ? "Agentic Data Kernel HTTP request timed out" + : "Agentic Data Kernel HTTP request failed", + ); + } + const body = parseJson(text); + if (!response.ok) { + const parsedError = errorResponseSchema.safeParse(body); + if (parsedError.success) { + throw new AgentDataHttpError( + response.status, + parsedError.data.error.code, + parsedError.data.error.message, + parsedError.data.requestId, + ); + } + throw new AgentDataHttpError( + response.status, + "http_error", + `Agentic Data Kernel returned HTTP ${response.status}`, + ); + } + const parsed = executionResultSchema.safeParse(body); + if (!parsed.success) { + throw new AgentDataHttpError( + response.status, + "invalid_response", + "Agentic Data Kernel returned an invalid execution response", + ); + } + assertCorrelatedResponse(parsed.data, envelope); + return { + ...parsed.data, + operation: envelope.operation.op, + }; + }, + }); +} + +function executionEndpoint(baseUrl: string): URL { + const url = new URL(z.string().url().parse(baseUrl)); + if (url.username || url.password) { + throw new Error("Agent middleware baseUrl must not contain credentials"); + } + if ( + url.protocol !== "https:" && + !(url.protocol === "http:" && isLoopbackHost(url.hostname)) + ) { + throw new Error( + "Agent middleware HTTP requires HTTPS except for loopback development", + ); + } + url.search = ""; + url.hash = ""; + if (!url.pathname.endsWith("/")) { + url.pathname = `${url.pathname}/`; + } + return new URL("v1/execute", url); +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname + .toLowerCase() + .replace(/^\[|\]$/g, ""); + if (normalized === "localhost" || normalized === "::1") { + return true; + } + return ( + isIP(normalized) === 4 && + normalized.split(".")[0] === "127" + ); +} + +function parseJson(value: string): unknown { + try { + return JSON.parse(value) as unknown; + } catch { + return null; + } +} + +async function readLimitedBody( + response: Response, + maximumBytes: number, +): Promise { + if (!response.body) { + return ""; + } + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + size += value.length; + if (size > maximumBytes) { + await reader.cancel(); + throw new AgentDataHttpError( + response.status, + "response_too_large", + `Agentic Data Kernel HTTP response exceeded ${maximumBytes} bytes`, + ); + } + chunks.push(value); + } + return Buffer.concat(chunks).toString("utf8"); +} + +function assertCorrelatedResponse( + response: z.infer, + envelope: Parameters[0], +): void { + const mismatched = + response.protocolVersion !== envelope.protocolVersion || + response.requestId !== envelope.requestId || + response.operation !== envelope.operation.op || + response.receipt.tenantId !== envelope.principal.tenantId || + response.receipt.principalId !== envelope.principal.principalId || + response.receipt.purpose !== envelope.principal.purpose || + response.receipt.operation !== envelope.operation.op || + ( + !response.idempotentReplay && + response.receipt.requestId !== envelope.requestId + ) || + stableStringify(response.receipt.result) !== + stableStringify(response.result) || + response.receipt.resultHash !== + sha256(stableStringify(response.result)); + if (mismatched) { + throw new AgentDataHttpError( + 502, + "response_mismatch", + "Agentic Data Kernel response did not match the request identity", + ); + } +} + +function boundedInteger( + value: number, + minimum: number, + maximum: number, + field: string, +): number { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + throw new Error( + `${field} must be an integer from ${minimum} through ${maximum}`, + ); + } + return value; +} diff --git a/src/production/index.ts b/src/production/index.ts index 05a13d8..f8ee4d9 100644 --- a/src/production/index.ts +++ b/src/production/index.ts @@ -7,6 +7,14 @@ export { requireScope, revokeApiKey, } from "./auth.js"; +export { + AgentDataHttpError, + createProductionAgentMiddleware, + createProductionHttpAgentMiddleware, +} from "./agent.js"; +export type { + ProductionHttpAgentMiddlewareConfig, +} from "./agent.js"; export { assertRuntimeRoleSafe, bootstrapRuntimeRole, diff --git a/src/test/agent.test.ts b/src/test/agent.test.ts new file mode 100644 index 0000000..fb1930c --- /dev/null +++ b/src/test/agent.test.ts @@ -0,0 +1,773 @@ +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import test from "node:test"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { + DEFAULT_MODEL_OPERATION_NAMES, + createEmbeddedAgentMiddleware, + type AgentToolDefinition, +} from "../agent.js"; +import { AgenticKernel, KernelError } from "../kernel.js"; +import { + AgentDataHttpError, + createProductionHttpAgentMiddleware, +} from "../production/agent.js"; +import { SqliteStore } from "../store.js"; +import type { JsonValue, PrincipalContext } from "../types.js"; +import { sha256, stableStringify } from "../util.js"; + +const principal: PrincipalContext = { + tenantId: "agent-middleware-tenant", + principalId: "middleware-agent", + purpose: "incident-response", +}; + +test("agent middleware prepares model context and executes bound tools", async () => { + const store = new SqliteStore(":memory:"); + const kernel = new AgenticKernel(store); + try { + const middleware = createEmbeddedAgentMiddleware(kernel, principal, { + maxContextCharacters: 4_000, + }); + const session = middleware.beginRun({ + runId: "run:incident-1001", + taskId: "incident:1001", + conversationId: "conversation:1001", + durableMetadata: { source: "alert-manager" }, + }); + + assert.deepEqual(session.identity(), principal); + assert.equal(Object.isFrozen(DEFAULT_MODEL_OPERATION_NAMES), true); + assert.throws(() => + Reflect.apply( + Array.prototype.push, + DEFAULT_MODEL_OPERATION_NAMES, + ["put_artifact"], + ), + ); + assert.deepEqual(session.runInfo().durableMetadata, { + source: "alert-manager", + }); + assertModelToolContract(session.modelTools()); + + const entity = await session.invokeTool({ + callId: "create-service", + name: "execute_operation", + arguments: { + idempotencyKey: "create-service", + operation: { + op: "put_entity", + entity: { + entityId: "service:checkout", + entityType: "service", + canonicalName: "Checkout", + }, + }, + }, + }); + assert.equal(field(entity.result, "entityId"), "service:checkout"); + const replay = await session.invokeTool({ + callId: "create-service-retry", + name: "execute_operation", + arguments: { + idempotencyKey: "create-service", + operation: { + op: "put_entity", + entity: { + entityId: "service:checkout", + entityType: "service", + canonicalName: "Checkout", + }, + }, + }, + }); + assert.equal(replay.idempotentReplay, true); + assert.equal(replay.receipt.receiptId, entity.receipt.receiptId); + + await session.execute({ + op: "assert", + assertion: { + assertionId: "assertion:error-rate", + subjectEntityId: "service:checkout", + predicate: "error_rate", + object: { type: "number", value: 0.42 }, + kind: "observation", + }, + }); + await session.execute({ + op: "create_workflow", + instanceId: "incident:1001", + workflowType: "incident_response", + initialState: "investigating", + data: { + summary: "x".repeat(5_000), + }, + }); + + const prepared = await session.prepareModelInput({ + query: "checkout error rate", + resolutions: [ + { + subjectEntityId: "service:checkout", + predicate: "error_rate", + policy: "latest", + }, + ], + workflow: { instanceId: "incident:1001" }, + maxCharacters: 1_000, + }); + assert.deepEqual( + prepared.context.sections.map((section) => section.type), + ["search", "resolution", "workflow"], + ); + assert.equal( + prepared.context.includedReceiptIds.length + + prepared.context.partialReceiptIds.length + + prepared.context.omittedReceiptIds.length, + 3, + ); + assert.equal(prepared.context.truncated, true); + assert.ok(prepared.context.modelContext.length <= 1_000); + const visibleContext = JSON.parse( + prepared.context.modelContext, + ) as { + sections: Array<{ status: string; receiptId: string }>; + }; + assert.equal(visibleContext.sections.length, 3); + assert.ok( + visibleContext.sections.every( + (section) => + section.status === "complete" || + section.status === "truncated" || + section.status === "omitted", + ), + ); + + const recorded = await session.recordTurn({ + turnId: "turn-1", + input: { role: "user", content: "Investigate checkout" }, + output: { role: "assistant", content: "Error rate is elevated" }, + contextReceiptIds: prepared.context.includedReceiptIds, + toolCalls: [ + { + name: entity.name, + arguments: { operation: "put_entity" }, + result: entity.result, + receiptId: entity.receipt.receiptId, + }, + ], + }); + const recordedReplay = await session.recordTurn({ + turnId: "turn-1", + input: { role: "user", content: "Investigate checkout" }, + output: { role: "assistant", content: "Error rate is elevated" }, + contextReceiptIds: prepared.context.includedReceiptIds, + toolCalls: [ + { + name: entity.name, + arguments: { operation: "put_entity" }, + result: entity.result, + receiptId: entity.receipt.receiptId, + }, + ], + }); + assert.equal(recordedReplay.idempotentReplay, true); + assert.equal(recordedReplay.artifactId, recorded.artifactId); + const turnArtifact = kernel.getArtifact( + principal.tenantId, + recorded.artifactId, + ); + const turn = JSON.parse(turnArtifact.content) as { + run: { + taskId: string; + conversationId: string; + metadata: { source: string }; + }; + principal: PrincipalContext; + turn: { turnId: string; contextReceiptIds: string[] }; + }; + assert.equal(turn.run.taskId, "incident:1001"); + assert.equal(turn.run.conversationId, "conversation:1001"); + assert.equal(turn.run.metadata.source, "alert-manager"); + assert.deepEqual(turn.principal, principal); + assert.equal(turn.turn.turnId, "turn-1"); + assert.deepEqual( + turn.turn.contextReceiptIds, + prepared.context.includedReceiptIds, + ); + + await assert.rejects( + () => + session.invokeTool({ + name: "execute_operation", + arguments: { + operation: { + op: "record_effect_outcome", + effectId: "effect:blocked", + idempotencyKey: "blocked", + status: "succeeded", + }, + }, + }), + (error: unknown) => + error instanceof KernelError && + error.code === "unauthorized" && + /does not expose record_effect_outcome/.test(error.message), + ); + + const restrictedSession = createEmbeddedAgentMiddleware( + kernel, + principal, + { + allowedOperations: ["search"], + maxTurnCharacters: 1_000, + }, + ).beginRun({ runId: "run:restricted" }); + assert.deepEqual( + restrictedSession.modelTools().map((tool) => tool.name), + ["search_knowledge", "execute_operation"], + ); + const executeSchema = JSON.stringify( + restrictedSession.modelTools().find( + (tool) => tool.name === "execute_operation", + )?.inputSchema, + ); + assert.match(executeSchema, /"const":"search"/); + assert.doesNotMatch(executeSchema, /"const":"put_entity"/); + await assert.rejects( + () => + restrictedSession.invokeTool({ + name: "execute_operation", + arguments: { + operation: { + op: "put_entity", + entity: { + entityId: "blocked", + entityType: "test", + canonicalName: "Blocked", + }, + }, + }, + }), + /does not expose put_entity/, + ); + await assert.rejects( + () => + restrictedSession.recordTurn({ + turnId: "oversized", + output: { text: "x".repeat(2_000) }, + }), + /Agent turn exceeds 1000/, + ); + const restrictedTurn = await restrictedSession.recordTurn({ + turnId: "host-owned", + output: { text: "Stored by the host, not exposed as a model tool." }, + }); + assert.ok(restrictedTurn.artifactId.startsWith("agent-turn:")); + assert.throws( + () => + createEmbeddedAgentMiddleware(kernel, principal, { + allowedOperations: ["seed_inventory"], + }), + /cannot expose privileged operation seed_inventory/, + ); + + await session.invokeTool({ + callId: "namespace-tool", + name: "execute_operation", + arguments: { + idempotencyKey: "turn:turn-2", + operation: { + op: "put_entity", + entity: { + entityId: "service:namespace", + entityType: "service", + canonicalName: "Namespace Test", + }, + }, + }, + }); + const namespaceTurn = await session.recordTurn({ + turnId: "turn-2", + output: { content: "Idempotency namespaces do not collide." }, + }); + assert.equal(namespaceTurn.idempotentReplay, false); + + const secondPrincipalSession = createEmbeddedAgentMiddleware( + kernel, + { + ...principal, + principalId: "second-middleware-agent", + }, + ).beginRun({ + runId: "run:incident-1001", + }); + const secondPrincipalTurn = await secondPrincipalSession.recordTurn({ + turnId: "turn-1", + output: { content: "Independent principal record." }, + }); + assert.notEqual( + secondPrincipalTurn.artifactId, + recorded.artifactId, + ); + + assert.throws( + () => + createEmbeddedAgentMiddleware( + kernel, + principal, + ).beginRun({ runId: "run\u0000segment" }), + /must not contain control characters/, + ); + await assert.rejects( + () => + session.execute( + { + op: "put_entity", + entity: { + entityId: "entity:null-boundary", + entityType: "test", + canonicalName: "Null Boundary", + }, + }, + { idempotencyKey: "key\u0000segment" }, + ), + /must not contain control characters/, + ); + } finally { + store.close(); + } +}); + +test("production HTTP middleware binds identity and maps API errors", async () => { + let rejectRequest = false; + let mismatchResponse = false; + let oversizedResponse = false; + let delayBodyResponse = false; + let capturedAuthorization = ""; + let capturedPurpose = ""; + let capturedBody: unknown; + const server = createServer(async (request, response) => { + capturedAuthorization = request.headers.authorization ?? ""; + capturedPurpose = request.headers["x-agent-purpose"]?.toString() ?? ""; + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.from(chunk)); + } + capturedBody = JSON.parse(Buffer.concat(chunks).toString("utf8")); + response.setHeader("content-type", "application/json"); + if (oversizedResponse) { + response.writeHead(200); + response.end("x".repeat(2_000)); + return; + } + if (delayBodyResponse) { + response.writeHead(200); + response.write('{"protocolVersion":"1.0",'); + await new Promise((resolve) => setTimeout(resolve, 1_500)); + response.end('"status":"ok"}'); + return; + } + if (rejectRequest) { + response.writeHead(403); + response.end( + JSON.stringify({ + error: { + code: "authorization_failed", + message: "Scope data:read is required", + }, + requestId: "server-request", + }), + ); + return; + } + const envelope = capturedBody as { + requestId: string; + operation: { op: string }; + principal: PrincipalContext; + }; + const result = { + instanceId: "incident:http", + state: "investigating", + }; + response.writeHead(200); + response.end( + JSON.stringify({ + protocolVersion: "1.0", + requestId: envelope.requestId, + status: "ok", + operation: envelope.operation.op, + result, + receipt: { + tenantId: mismatchResponse + ? "different-tenant" + : envelope.principal.tenantId, + receiptId: "receipt:http", + requestId: envelope.requestId, + principalId: envelope.principal.principalId, + purpose: envelope.principal.purpose, + operation: envelope.operation.op, + snapshotTime: "2026-09-04T00:00:00.000Z", + evidenceManifest: [], + resultHash: sha256(stableStringify(result)), + result, + createdAt: "2026-09-04T00:00:00.000Z", + }, + idempotentReplay: false, + }), + ); + }); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const port = (server.address() as AddressInfo).port; + try { + const session = createProductionHttpAgentMiddleware({ + baseUrl: `http://127.0.0.1:${port}`, + apiKey: "test-api-key", + principal, + }).beginRun({ runId: "run:http" }); + const result = await session.invokeTool({ + callId: "machine", + name: "get_machine", + arguments: { instanceId: "incident:http" }, + }); + assert.equal(capturedAuthorization, "Bearer test-api-key"); + assert.equal(capturedPurpose, principal.purpose); + assert.deepEqual( + (capturedBody as { principal: PrincipalContext }).principal, + principal, + ); + assert.equal(field(result.result, "state"), "investigating"); + + rejectRequest = true; + await assert.rejects( + () => + session.invokeTool({ + callId: "machine-forbidden", + name: "get_machine", + arguments: { instanceId: "incident:http" }, + }), + (error: unknown) => + error instanceof AgentDataHttpError && + error.status === 403 && + error.code === "authorization_failed" && + error.requestId === "server-request", + ); + rejectRequest = false; + mismatchResponse = true; + await assert.rejects( + () => + session.invokeTool({ + callId: "machine-mismatch", + name: "get_machine", + arguments: { instanceId: "incident:http" }, + }), + (error: unknown) => + error instanceof AgentDataHttpError && + error.status === 502 && + error.code === "response_mismatch", + ); + mismatchResponse = false; + + oversizedResponse = true; + const sizeLimitedSession = createProductionHttpAgentMiddleware({ + baseUrl: `http://127.0.0.1:${port}`, + apiKey: "test-api-key", + principal, + maxResponseBytes: 1_024, + }).beginRun({ runId: "run:http-size" }); + await assert.rejects( + () => + sizeLimitedSession.invokeTool({ + callId: "machine-large", + name: "get_machine", + arguments: { instanceId: "incident:http" }, + }), + (error: unknown) => + error instanceof AgentDataHttpError && + error.code === "response_too_large", + ); + oversizedResponse = false; + + delayBodyResponse = true; + const timeoutSession = createProductionHttpAgentMiddleware({ + baseUrl: `http://127.0.0.1:${port}`, + apiKey: "test-api-key", + principal, + requestTimeoutMs: 1_000, + }).beginRun({ runId: "run:http-timeout" }); + await assert.rejects( + () => + timeoutSession.invokeTool({ + callId: "machine-timeout", + name: "get_machine", + arguments: { instanceId: "incident:http" }, + }), + (error: unknown) => + error instanceof AgentDataHttpError && + error.status === 504 && + error.code === "timeout", + ); + delayBodyResponse = false; + + assert.throws( + () => + createProductionHttpAgentMiddleware({ + baseUrl: "http://database.example.com", + apiKey: "test-api-key", + principal, + }), + /requires HTTPS/, + ); + assert.throws( + () => + createProductionHttpAgentMiddleware({ + baseUrl: "http://127.example.com", + apiKey: "test-api-key", + principal, + }), + /requires HTTPS/, + ); + assert.throws( + () => + createProductionHttpAgentMiddleware({ + baseUrl: "http://127.0.0.1.example.com", + apiKey: "test-api-key", + principal, + }), + /requires HTTPS/, + ); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +}); + +function assertModelToolContract(tools: AgentToolDefinition[]): void { + assert.deepEqual( + tools.map((tool) => tool.name), + [ + "search_knowledge", + "resolve_claims", + "get_machine", + "list_effects", + "explain_trace", + "execute_operation", + ], + ); + const serialized = JSON.stringify(tools); + assert.doesNotMatch(serialized, /tenantId|principalId|purpose/); + assert.equal( + tools.find((tool) => tool.name === "search_knowledge")?.readOnly, + true, + ); + assert.equal( + tools.find((tool) => tool.name === "execute_operation")?.readOnly, + false, + ); + const resolveSchema = requiredTool( + tools, + "resolve_claims", + ).inputSchema; + assert.ok( + !arrayField(resolveSchema, "required").includes("policy"), + ); + const ajv = new Ajv2020({ + strict: false, + formats: { + "date-time": { + type: "string", + validate: (value: string) => !Number.isNaN(Date.parse(value)), + }, + }, + }); + const validateResolve = ajv.compile(resolveSchema); + assert.equal( + validateResolve({ + subjectEntityId: "service:checkout", + predicate: "owner", + }), + true, + ); + const validateSearch = ajv.compile( + requiredTool(tools, "search_knowledge").inputSchema, + ); + assert.equal(validateSearch({ text: " " }), false); + + const executeSchema = requiredTool( + tools, + "execute_operation", + ).inputSchema; + const operationSchema = objectField( + objectField(executeSchema, "properties"), + "operation", + ); + const payment = operationVariant( + operationSchema, + "request_payment", + ); + assert.deepEqual( + objectField( + objectField( + objectField(payment, "properties"), + "amount", + ), + "not", + ).enum, + ["0", "0.0", "0.00", "0.000", "0.0000"], + ); + const effect = operationVariant( + operationSchema, + "request_effect", + ); + assert.ok(Array.isArray(effect.allOf)); + const validateExecute = ajv.compile(executeSchema); + assert.equal( + validateExecute({ + operation: { + op: "request_payment", + instanceId: "order:1", + amount: "0", + currency: "USD", + paymentTarget: "https://payments.example.com/capture", + idempotencyKey: "payment-1", + }, + }), + false, + ); + assert.equal( + validateExecute({ + operation: { + op: "request_payment", + instanceId: "order:1", + amount: "0.01", + currency: "USD", + paymentTarget: "https://payments.example.com/capture", + idempotencyKey: "payment-1", + }, + }), + true, + ); + assert.equal( + validateExecute({ + operation: { + op: "put_entity", + entity: { + entityId: " ", + entityType: "service", + canonicalName: "Checkout", + }, + }, + }), + false, + ); + assert.equal( + validateExecute({ + operation: { + op: "request_effect", + instanceId: "incident:1", + expectedRevision: 1, + effectName: "rollback", + effectType: "deployment.rollback", + target: "https://deployments.example.com/rollback", + request: {}, + idempotencyKey: "rollback-1", + decisionAssertionId: "assertion:decision", + policyAssertionId: "assertion:policy", + budgetAmount: "1", + }, + }), + false, + ); + assert.equal( + validateExecute({ + operation: { + op: "request_effect", + instanceId: "incident:1", + expectedRevision: 1, + effectName: "rollback", + effectType: "deployment.rollback", + target: "https://deployments.example.com/rollback", + request: {}, + idempotencyKey: "rollback-1", + decisionAssertionId: "assertion:decision", + policyAssertionId: "assertion:policy", + budgetAmount: "1", + currency: "USD", + }, + }), + true, + ); +} + +function field(value: JsonValue, name: string): string { + if ( + value === null || + Array.isArray(value) || + typeof value !== "object" || + typeof value[name] !== "string" + ) { + throw new Error(`Expected ${name}`); + } + return value[name]; +} + +function requiredTool( + tools: AgentToolDefinition[], + name: AgentToolDefinition["name"], +): AgentToolDefinition { + const tool = tools.find((candidate) => candidate.name === name); + if (!tool) { + throw new Error(`Expected tool ${name}`); + } + return tool; +} + +function operationVariant( + operationSchema: Record, + operationName: string, +): Record { + const variants = arrayField(operationSchema, "oneOf"); + const variant = variants.find((candidate) => { + if (!isObject(candidate)) { + return false; + } + return ( + objectField( + objectField(candidate, "properties"), + "op", + ).const === operationName + ); + }); + if (!isObject(variant)) { + throw new Error(`Expected operation schema ${operationName}`); + } + return variant; +} + +function objectField( + value: Record, + fieldName: string, +): Record { + const fieldValue = value[fieldName]; + if (!isObject(fieldValue)) { + throw new Error(`Expected object field ${fieldName}`); + } + return fieldValue; +} + +function arrayField( + value: Record, + fieldName: string, +): unknown[] { + const fieldValue = value[fieldName]; + if (!Array.isArray(fieldValue)) { + return []; + } + return fieldValue; +} + +function isObject(value: unknown): value is Record { + return value !== null && !Array.isArray(value) && typeof value === "object"; +} diff --git a/src/test/production.test.ts b/src/test/production.test.ts index 561e507..7a3a640 100644 --- a/src/test/production.test.ts +++ b/src/test/production.test.ts @@ -59,6 +59,7 @@ import { SecureHttpEffectTransport, type EffectTransport, } from "../production/effects.js"; +import { createProductionAgentMiddleware } from "../production/agent.js"; import { resolveClientAddress, startProductionHttpServer, @@ -1329,6 +1330,35 @@ test( text: "product weight", }); assert.equal(search[0]?.assertion.assertionId, "assertion:weight"); + const agentSession = createProductionAgentMiddleware( + kernel, + principalA, + ).beginRun({ + runId: "run:production-agent", + taskId: "task:production-agent", + }); + const agentContext = await agentSession.compileContext({ + query: "product weight", + resolutions: [ + { + subjectEntityId: "product:1", + predicate: "packaged_weight", + policy: "latest", + }, + ], + }); + assert.deepEqual( + agentContext.sections.map((section) => section.type), + ["search", "resolution"], + ); + const recordedAgentTurn = await agentSession.recordTurn({ + turnId: "turn-1", + input: { message: "What is the packaged weight?" }, + output: { message: "The current value is 4.8 kg." }, + }); + assert.ok( + recordedAgentTurn.artifactId.startsWith("agent-turn:"), + ); await execute(kernel, principalA, "generic-decision", { op: "assert",