Compare commits

..

2 Commits

Author SHA1 Message Date
hongjr03 8df8156969 feat(hub): external capability registry for PDF/ASR transforms (ADR-0027)
Introduces the External Capability abstraction: platform-registered,
org-enabled document/media transform services invoked as side effects of
an AgentRun. The first concrete capability is pdf_to_md_bundle backed by
MinerU. This lands the contract, schema, adapter interface, and a fully
mock-tested adapter; the real MinerU HTTP client is deferred until
credentials and pricing are confirmed.

Contract:
- spec/Spec/System/Agent/Capability.lean pins ExternalCapability,
  OrganizationCapabilityConnection (reuses ADR-0024 envelope, distinct
  from model-provider connection), and CapabilityInvocation.Authorized
  (workspace containment predicate, ADR-0018). lake build green (41 jobs).
- ADR-0027 records why capability credentials are a separate connection
  type (MinerU/Whisper have their own auth shape, not OpenRouter /v1/models),
  the adapter seam, and what is deferred (capability invocation record,
  pricebook, agent-facing tool discovery).

Schema:
- OrganizationCapabilityConnection + CapabilityCredentialVersion, mirroring
  the Feishu Application Connection shape. Unique by (organizationId,
  capabilityId). Migration 20260718130000 applied.

Adapter (hub/src/capability):
- types.ts: CapabilityId, CapabilityAdapter interface, secret payload,
  CapabilityConnectionUnavailable.
- mineruClient.ts: MineruClient interface + MineruParseResult + errors.
  Real HTTP client deferred; the interface is the seam.
- capabilityConnections.ts: resolveCapabilityCredential — org-scoped,
  fail-closed, reuses LocalSecretEnvelope with purpose="capability".
- pdfToMdBundle.ts: adapter that resolves credential, confines input/output
  to the run workspace (ADR-0018), calls MineruClient, writes md + images
  into workspace, and writes a UsageFact (kind=external_capability,
  quantity=pages, costSource=provider_reported|unknown per ADR-0022).

Tests (7, all green):
- md + images written to workspace, UsageFact attributes pages + cost
- costUsd null -> costSource=unknown (missing cost != zero, ADR-0022)
- no ACTIVE connection -> fail-closed, no fact written
- input/output path escape -> CapabilityPathEscape
- MinerU client error propagates, no fact written
- empty markdown -> parse failure

Deferred (needs operator): MinerU account/API key/pricing, real MineruClient
HTTP implementation, org admin UI for capability connection management,
agent-facing tool exposure (MCP vs built-in).
2026-07-18 15:54:00 +08:00
hongjr03 aaa098bb8b feat(hub): usage fact ledger for run-scoped cost attribution (ADR-0026) (#4)
Co-authored-by: Hong Jiarong <me@jrhim.com>
Co-committed-by: Hong Jiarong <me@jrhim.com>
2026-07-18 14:46:41 +08:00
10 changed files with 1057 additions and 1 deletions
@@ -0,0 +1,152 @@
# ADR 0027: External Capability Registry
## Status
Accepted.
## Context
ADR-0026 introduced the `UsageFact` ledger with a `kind = external_capability`
fact and a `capabilityId` field, but deferred the capability registry itself.
Two concrete needs now force the issue:
- **PDF→Markdown bundle** conversion (and, imminently, audio/video→text)
must run as a side effect of an `AgentRun`, bill in non-token units
(pages, seconds), use a different provider than the model loop, and report
cost through a different channel. It is not a sub-run (ADR-0026 rejected
that) and not a model-provider call (it does not speak the Anthropic/
OpenRouter protocol).
- The Agent already has a Bash tool. Without a first-class capability seam,
the path of least resistance is for the agent to shell out to ad-hoc
scripts that embed API keys, write to arbitrary paths, and report nothing
to the ledger. That is exactly the unattributed, uncontained external
consumption ADR-0022/0026 exist to prevent.
The model-provider connection (`OrganizationProviderConnection`, ADR-0024)
is the wrong seam for these services:
- Its payload schema (`baseUrl` + `authToken` + `anthropicApiKey`) and
readiness probe (`/v1/models?supported_parameters=tools`) are specific to
OpenRouter/Anthropic. MinerU, Whisper, and future OCR/ASR services have
different auth shapes (an API token, optionally a project id) and no
`/v1/models` endpoint.
- Its uniqueness key is `(organizationId, providerId)` where `providerId`
is an OpenRouter-style model-routing id. A capability provider id
(`mineru`) names a *service*, not a model.
- Coupling capability credentials into the model-provider table would force
every capability's auth shape through `ProviderSecretPayloadV1` and every
readiness probe through `probeOpenRouterCredential`.
The Feishu Application Connection (`OrganizationFeishuApplicationConnection`)
is the right structural precedent: it reuses the ADR-0024 envelope (KEK →
DEK → AES-256-GCM, AAD-bound to purpose/org/connection/version) but has its
own connection table, its own payload schema, its own readiness probe, and
its own per-org uniqueness. Capability connections follow the same pattern.
## Decision
### External Capability
An **External Capability** is a platform-registered, org-enabled document or
media transform service invoked as a side effect of an `AgentRun`. It is
identified by a stable `capabilityId` (e.g. `pdf_to_md_bundle`,
`audio_video_to_text`). A capability:
- Has an **input kind** (PDF, image, audio, video, …) and an **output
contract** (markdown bundle with extracted images, transcript text, …).
- Bills in **non-token units** (pages, audio-seconds) recorded on a
`UsageFact` with `kind = external_capability`, or in tokens when the
backing service reports them.
- Writes its output **into the invoking run's workspace** (ADR-0018
`AgentSurface` — no escapes).
- Is invoked through a **capability adapter** in Hub, never by the Agent
shelling out with embedded credentials.
### Capability Connection
Credentials for a capability live in an **`OrganizationCapabilityConnection`**,
structurally identical to the Feishu Application Connection:
- Belongs to exactly one Organization.
- Unique by `(organizationId, capabilityId)`.
- `DRAFT` / `ACTIVE` / `DISABLED`; resolution accepts only `ACTIVE` with a
valid active secret version.
- Secret material is an immutable, AAD-bound, KEK-wrapped envelope version
(`CapabilityCredentialVersion`), reusing the ADR-0024 encryption
machinery with `purpose = "capability"`.
- Its payload schema is capability-specific (`CapabilitySecretPayloadV1`:
`baseUrl`, `apiToken`, optional `projectId`). New capability types extend
the payload, not the connection table.
- A capability-specific **readiness probe** validates the credential before
activation (e.g. MinerU: a trivial authenticated GET). The probe is
injectable, matching the Feishu/provider pattern, so tests never hit the
network.
### Capability Adapter
The adapter is the seam between the Agent and the external service. It:
- Resolves the org's active capability connection (fail-closed, no
process-global fallback — ADR-0024).
- Accepts a workspace-relative input path and an output directory.
- Calls the backing service (MinerU, Whisper, …) via an injectable
`Client` interface so the real HTTP client is swappable and mockable.
- Writes the produced markdown + image assets into the run's workspace.
- Writes one `UsageFact` (or more, if the service reports per-stage
consumption) with `kind = external_capability`, `capabilityId`,
`provider` (the service id), `quantity + unit` (pages / seconds), and
`costUsd + costSource = provider_reported` when the service reports cost.
### Registry
The platform maintains a **registry** of known capabilities: their id,
input kind, output contract, metering unit, and adapter. This is
code-level registration (like `ToolRegistry`), not a database table — a
capability is available to an Organization only when (a) the platform
knows the adapter and (b) the Organization has an `ACTIVE` connection for
it. Both gates are required.
### What is NOT in this ADR
- The capability invocation is **not** a first-class persisted record
(`CapabilityInvocation` table) in this ADR. The `UsageFact` row with
`capabilityId` + `correlationId` is the durable trace. If we later need
a richer invocation log (retries, partial output, multi-stage status),
that is a follow-up; for now the fact is enough.
- **Pricebook** remains deferred (ADR-0026). Capability facts use
`provider_reported` when the service returns cost; otherwise `unknown`.
- **Org-scoped enable/disable policy** beyond connection status is
deferred. An org with an `ACTIVE` connection has the capability; one
without does not. A finer "enabled but no credential" toggle is not
needed yet.
- **Agent-facing tool exposure** (how the Agent discovers and calls the
capability — MCP tool, Bash wrapper, or built-in) is an implementation
detail of the adapter wiring, not a contract concern. The contract pins
that the Agent never receives the capability credential.
## Consequences
- Adding a new external capability (e.g. `image_ocr`) is: register an
adapter, add a `capabilityId` constant, optionally extend the secret
payload — no schema change to `UsageFact` or `AgentRun`.
- The model-provider connection table stays focused on model routing;
capability credentials do not pollute its payload or readiness probe.
- Three connection types now share the ADR-0024 envelope: model-provider,
Feishu application, and capability. Each has its own table, payload
schema, and probe, but the same encryption, rotation, and resolver
boundary.
- The Agent's Bash tool remains available, but the intended path for
document/media transforms is the capability adapter. Whether to narrow
Bash for capability-shaped tasks is an operational policy decision,
not a contract one.
- Tests prove: workspace containment of capability output, fail-closed
credential resolution, `UsageFact` attribution with non-token metering,
and that the Agent process never receives the capability credential.
## Deferred
- `CapabilityInvocation` as a first-class durable record (status, retries,
partial output) — currently the `UsageFact` row is the only trace.
- Pricebook derivation for capability costs (ADR-0026 deferred).
- Org-scoped capability enable/disable policy finer than connection status.
- Agent-facing tool discovery (MCP vs built-in) for capabilities.
@@ -0,0 +1,69 @@
-- ADR-0027: org-scoped capability connections for external document/media
-- transforms (PDF→MD, audio/video→text, …). Mirrors the Feishu Application
-- Connection shape: reuses the ADR-0024 envelope machinery with its own
-- payload schema and readiness probe, distinct from the model-provider
-- connection.
CREATE TABLE "OrganizationCapabilityConnection" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"capabilityId" TEXT NOT NULL,
"status" "OrganizationConnectionStatus" NOT NULL DEFAULT 'DRAFT',
"activeSecretVersionId" TEXT,
"activatedAt" TIMESTAMP(3),
"disabledAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "OrganizationCapabilityConnection_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_key"
ON "OrganizationCapabilityConnection"("organizationId", "capabilityId");
CREATE UNIQUE INDEX "OrganizationCapabilityConnection_activeSecretVersionId_key"
ON "OrganizationCapabilityConnection"("activeSecretVersionId");
CREATE INDEX "OrganizationCapabilityConnection_organizationId_status_idx"
ON "OrganizationCapabilityConnection"("organizationId", "status");
CREATE INDEX "OrganizationCapabilityConnection_capabilityId_status_idx"
ON "OrganizationCapabilityConnection"("capabilityId", "status");
CREATE TABLE "CapabilityCredentialVersion" (
"id" TEXT NOT NULL,
"connectionId" TEXT NOT NULL,
"version" INTEGER NOT NULL,
"envelopeVersion" INTEGER NOT NULL DEFAULT 1,
"keyId" TEXT NOT NULL,
"envelope" JSONB NOT NULL,
"createdByUserId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"retiredAt" TIMESTAMP(3),
CONSTRAINT "CapabilityCredentialVersion_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "CapabilityCredentialVersion_connectionId_version_key"
ON "CapabilityCredentialVersion"("connectionId", "version");
CREATE INDEX "CapabilityCredentialVersion_connectionId_retiredAt_idx"
ON "CapabilityCredentialVersion"("connectionId", "retiredAt");
CREATE INDEX "CapabilityCredentialVersion_keyId_idx"
ON "CapabilityCredentialVersion"("keyId");
CREATE INDEX "CapabilityCredentialVersion_createdByUserId_idx"
ON "CapabilityCredentialVersion"("createdByUserId");
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "OrganizationCapabilityConnection"
ADD CONSTRAINT "OrganizationCapabilityConnection_activeSecretVersionId_fkey"
FOREIGN KEY ("activeSecretVersionId") REFERENCES "CapabilityCredentialVersion"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_connectionId_fkey"
FOREIGN KEY ("connectionId") REFERENCES "OrganizationCapabilityConnection"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "CapabilityCredentialVersion"
ADD CONSTRAINT "CapabilityCredentialVersion_createdByUserId_fkey"
FOREIGN KEY ("createdByUserId") REFERENCES "User"("id")
ON DELETE SET NULL ON UPDATE CASCADE;
+54
View File
@@ -44,6 +44,7 @@ model Organization {
externalDirectoryConnections ExternalDirectoryConnection[]
providerConnections OrganizationProviderConnection[]
feishuApplicationConnection OrganizationFeishuApplicationConnection?
capabilityConnections OrganizationCapabilityConnection[]
agentSkills OrganizationAgentSkill[]
agentRoles OrganizationAgentRole[]
projectGroupBindings ProjectGroupBinding[]
@@ -198,6 +199,7 @@ model User {
auditEntries AuditEntry[] @relation("auditActor")
providerCredentialVersions ProviderCredentialVersion[] @relation("providerCredentialVersionCreator")
feishuCredentialVersions FeishuApplicationCredentialVersion[] @relation("feishuCredentialVersionCreator")
capabilityCredentialVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersionCreator")
feishuIdentities FeishuUserIdentity[]
}
@@ -869,3 +871,55 @@ model UsageFact {
@@index([provider, model, occurredAt])
@@index([capabilityId, occurredAt])
}
// --- External capability connections (ADR-0027) -------------------------
/// ADR-0027: org-scoped credential connection for an external capability
/// (PDF→MD, audio/video→text, …). Structurally mirrors the Feishu Application
/// Connection: reuses the ADR-0024 envelope (KEK→DEK→AES-256-GCM, AAD-bound)
/// but has its own payload schema and readiness probe, distinct from the
/// model-provider connection. Unique by (organizationId, capabilityId).
model OrganizationCapabilityConnection {
id String @id @default(cuid())
organizationId String
capabilityId String
status OrganizationConnectionStatus @default(DRAFT)
activeSecretVersionId String? @unique
activatedAt DateTime?
disabledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
secretVersions CapabilityCredentialVersion[] @relation("capabilityCredentialVersions")
activeSecretVersion CapabilityCredentialVersion? @relation("activeCapabilityCredentialVersion", fields: [activeSecretVersionId], references: [id], onDelete: Restrict)
@@unique([organizationId, capabilityId])
@@index([organizationId, status])
@@index([capabilityId, status])
}
/// ADR-0024/0027: one immutable authenticated envelope per capability secret
/// version. Same encryption machinery as Provider/Feishu credential versions;
/// the payload inside is CapabilitySecretPayloadV1 (baseUrl, apiToken,
/// optional projectId).
model CapabilityCredentialVersion {
id String @id @default(cuid())
connectionId String
version Int
envelopeVersion Int @default(1)
keyId String
envelope Json
createdByUserId String?
createdAt DateTime @default(now())
retiredAt DateTime?
connection OrganizationCapabilityConnection @relation("capabilityCredentialVersions", fields: [connectionId], references: [id], onDelete: Cascade)
activeFor OrganizationCapabilityConnection? @relation("activeCapabilityCredentialVersion")
createdBy User? @relation("capabilityCredentialVersionCreator", fields: [createdByUserId], references: [id], onDelete: SetNull)
@@unique([connectionId, version])
@@index([connectionId, retiredAt])
@@index([keyId])
@@index([createdByUserId])
}
@@ -0,0 +1,70 @@
/**
* ADR-0027: org-scoped capability credential resolver. Reuses the ADR-0024
* envelope decryption machinery (LocalSecretEnvelope) with
* purpose="capability", distinct from the model-provider and Feishu
* application connections. Fail-closed: no process-global fallback.
*
* Mirrors the Feishu application connection resolver shape, minus the
* readiness probe (capability probes are per-capability and injected by the
* adapter wiring, not this resolver).
*/
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope, type SecretEnvelopeV1 } from "../security/secretEnvelope.js";
import {
CapabilityConnectionUnavailable,
type CapabilitySecretPayload,
type CapabilityId,
} from "./types.js";
const CAPABILITY_PURPOSE = "capability";
export interface ResolvedCapabilityCredential extends CapabilitySecretPayload {
readonly connectionId: string;
readonly organizationId: string;
readonly capabilityId: string;
}
/**
* Resolve the active capability credential for an organization. Throws
* CapabilityConnectionUnavailable when the org has no ACTIVE connection
* (fail-closed, ADR-0024). Decrypted plaintext exists only in the returned
* object for the duration of the capability call; it is never cached, logged,
* or passed to the Agent process.
*/
export async function resolveCapabilityCredential(
prisma: PrismaClient,
secrets: LocalSecretEnvelope,
input: { readonly organizationId: string; readonly capabilityId: CapabilityId },
): Promise<ResolvedCapabilityCredential> {
const connection = await prisma.organizationCapabilityConnection.findFirst({
where: {
organizationId: input.organizationId,
capabilityId: input.capabilityId,
status: "ACTIVE",
},
include: { activeSecretVersion: true },
});
if (connection === null || connection.activeSecretVersion === null) {
throw new CapabilityConnectionUnavailable(input.capabilityId, input.organizationId);
}
const version = connection.activeSecretVersion;
const binding = {
purpose: CAPABILITY_PURPOSE,
organizationId: connection.organizationId,
connectionId: connection.id,
secretVersionId: version.id,
};
const payload = secrets.decryptJson<CapabilitySecretPayload>(binding, version.envelope as unknown as SecretEnvelopeV1);
if (payload.schemaVersion !== 1) {
throw new Error(`unsupported capability secret schemaVersion: ${payload.schemaVersion}`);
}
return {
connectionId: connection.id,
organizationId: connection.organizationId,
capabilityId: connection.capabilityId,
schemaVersion: 1,
baseUrl: payload.baseUrl,
apiToken: payload.apiToken,
projectId: payload.projectId,
};
}
+66
View File
@@ -0,0 +1,66 @@
/**
* ADR-0027: MinerU client interface. Isolates the real HTTP client so the
* adapter is testable without network access. The real implementation (filling
* in actual MinerU API calls) is deferred until credentials and pricing are
* confirmed; the interface and a mock implementation land now so the adapter
* wiring, UsageFact attribution, and workspace containment are provable.
*
* MinerU cloud API shape (from mineru.net docs / GitHub README):
* - REST API, async task endpoint POST /tasks (v3.0+) + sync POST /file_parse
* - Auth: Bearer token (apiToken)
* - Output: Markdown + extracted images, returned as a zip or structured JSON
* - Cost: reported per-page (pricing requires account confirmation)
*
* The interface models the synchronous parse path for simplicity; the real
* client may poll the async endpoint internally and is free to do so behind
* this signature.
*/
import type { CapabilitySecretPayload } from "./types.js";
/** A single extracted image from the parsed document. */
export interface MineruExtractedImage {
/** Suggested relative filename (e.g. "page_1_fig_0.jpg"). */
readonly filename: string;
/** Raw image bytes. */
readonly data: Uint8Array;
}
/** The structured result of parsing one document. */
export interface MineruParseResult {
/** Markdown text with image references (relative to output dir). */
readonly markdown: string;
/** Images extracted from the document, to be written alongside the md. */
readonly images: readonly MineruExtractedImage[];
/** Number of pages processed (for UsageFact quantity, unit "pages"). */
readonly pageCount: number;
/** USD cost if the API reported it; null if unknown (ADR-0022). */
readonly costUsd: number | null;
/** External task/request id for the UsageFact correlationId. */
readonly requestId: string | null;
}
/** Options passed to the client. */
export interface MineruParseOptions {
/** Absolute path to the input PDF on the Hub's filesystem. */
readonly inputFilePath: string;
}
/**
* Client interface for the MinerU document parsing service. The real
* implementation makes authenticated HTTP calls; tests inject a mock.
*/
export interface MineruClient {
parse(credential: CapabilitySecretPayload, options: MineruParseOptions): Promise<MineruParseResult>;
}
/** Errors raised by the MinerU client. */
export class MineruClientError extends Error {
constructor(
message: string,
readonly code: "mineru_unreachable" | "mineru_rejected" | "mineru_invalid_response" | "mineru_no_output",
readonly upstreamStatus?: number,
) {
super(message);
this.name = "MineruClientError";
}
}
+152
View File
@@ -0,0 +1,152 @@
/**
* ADR-0027: pdf_to_md_bundle capability adapter.
*
* Converts a PDF in the run's workspace into a Markdown bundle (md + extracted
* images) by calling the MinerU document parsing service, writing outputs into
* the workspace, and recording consumption on a UsageFact (ADR-0026).
*
* Invariants (ADR-0027):
* 1. Credential isolation — the capability credential is resolved in Hub and
* never reaches the Agent process. The MineruClient receives it as a
* call argument, not from the environment.
* 2. Workspace containment — input and output paths are confined to the
* run's workspace dir (ADR-0018 AgentSurface). Escapes are rejected.
* 3. Mandatory fact — a successful invocation always writes ≥1 UsageFact
* with kind=external_capability, even when costUsd is null (ADR-0022:
* missing cost ≠ zero).
*/
import { mkdir, writeFile } from "node:fs/promises";
import { join, resolve, relative, isAbsolute } from "node:path";
import type { PrismaClient } from "@prisma/client";
import { LocalSecretEnvelope } from "../security/secretEnvelope.js";
import { resolveCapabilityCredential } from "./capabilityConnections.js";
import { MineruClientError, type MineruClient } from "./mineruClient.js";
import {
CAPABILITIES,
type CapabilityAdapter,
type CapabilityInvocationInput,
type CapabilityInvocationResult,
type CapabilityOutputArtifact,
} from "./types.js";
const CAPABILITY_ID = "pdf_to_md_bundle" as const;
const PROVIDER_ID = "mineru";
/** Thrown when a requested path escapes the workspace root (ADR-0018). */
export class CapabilityPathEscape extends Error {
constructor(readonly requested: string, readonly workspaceDir: string) {
super(`capability path escapes workspace: ${requested} (root ${workspaceDir})`);
this.name = "CapabilityPathEscape";
}
}
/** Resolve a workspace-relative path, rejecting escapes (ADR-0018 AgentSurface). */
function confineToWorkspace(requestedPath: string, workspaceDir: string): string {
if (isAbsolute(requestedPath)) {
const rel = relative(workspaceDir, requestedPath);
if (rel.startsWith("..") || rel === "") {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return requestedPath;
}
const resolved = resolve(workspaceDir, requestedPath);
const rel = relative(workspaceDir, resolved);
if (rel.startsWith("..")) {
throw new CapabilityPathEscape(requestedPath, workspaceDir);
}
return resolved;
}
export interface PdfToMdBundleDeps {
readonly secrets: LocalSecretEnvelope;
readonly client: MineruClient;
readonly prisma: PrismaClient;
}
/** Build the pdf_to_md_bundle adapter. The MineruClient is injectable for testing. */
export function createPdfToMdBundleAdapter(deps: PdfToMdBundleDeps): CapabilityAdapter {
return {
capabilityId: CAPABILITY_ID,
async invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult> {
const descriptor = CAPABILITIES[CAPABILITY_ID];
// 1. Resolve org-scoped credential (fail-closed, ADR-0024/0027).
const credential = await resolveCapabilityCredential(deps.prisma, deps.secrets, {
organizationId: input.organizationId,
capabilityId: CAPABILITY_ID,
});
// 2. Confine input + output paths to the workspace (ADR-0018).
const absoluteInput = confineToWorkspace(input.inputPath, input.workspaceDir);
const absoluteOutputDir = confineToWorkspace(input.outputDir, input.workspaceDir);
await mkdir(absoluteOutputDir, { recursive: true });
// 3. Call the backing service.
let result;
try {
result = await deps.client.parse(credential, { inputFilePath: absoluteInput });
} catch (e) {
if (e instanceof MineruClientError) throw e;
throw new MineruClientError(
e instanceof Error ? e.message : String(e),
"mineru_unreachable",
);
}
if (result.markdown === "") {
throw new MineruClientError("MinerU returned empty markdown", "mineru_no_output");
}
// 4. Write outputs into the workspace.
const artifacts: CapabilityOutputArtifact[] = [];
const mdPath = join(absoluteOutputDir, "document.md");
await writeFile(mdPath, result.markdown, "utf8");
artifacts.push({ path: relative(input.workspaceDir, mdPath), kind: "markdown" });
for (const image of result.images) {
const imagePath = join(absoluteOutputDir, image.filename);
const rel = relative(absoluteOutputDir, imagePath);
if (rel.startsWith("..")) {
// Defensive: image filename must not escape the output dir.
throw new CapabilityPathEscape(image.filename, absoluteOutputDir);
}
await writeFile(imagePath, image.data);
artifacts.push({ path: relative(input.workspaceDir, imagePath), kind: "image" });
}
// 5. Write the UsageFact (ADR-0026/0027). Always written on success;
// costUsd null means unknown, NOT zero (ADR-0022).
const occurredAt = new Date();
await deps.prisma.usageFact.create({
data: {
runId: input.runId,
occurredAt,
kind: "external_capability",
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
costSource: result.costUsd !== null ? "provider_reported" : "unknown",
capabilityId: CAPABILITY_ID,
correlationId: result.requestId,
metadata: {},
},
});
return {
artifacts,
consumption: {
provider: PROVIDER_ID,
model: null,
inputTokens: null,
outputTokens: null,
quantity: result.pageCount,
unit: descriptor.meteringUnit,
costUsd: result.costUsd,
correlationId: result.requestId,
},
};
},
};
}
+100
View File
@@ -0,0 +1,100 @@
/**
* ADR-0027: External capability types shared across the adapter layer.
*
* A capability is a platform-registered, org-enabled document/media transform
* invoked as a side effect of an AgentRun. The adapter resolves the org's
* active capability connection, calls the backing service via an injectable
* client, writes output into the run's workspace (AgentSurface, ADR-0018),
* and records consumption on a UsageFact (ADR-0026).
*/
import type { PrismaClient, Prisma } from "@prisma/client";
/** Stable capability identifiers registered with the platform (ADR-0027). */
export const CAPABILITY_IDS = [
"pdf_to_md_bundle",
"audio_video_to_text",
] as const;
export type CapabilityId = (typeof CAPABILITY_IDS)[number];
/** Non-token metering unit for a capability (ADR-0026/0027). */
export interface CapabilityDescriptor {
readonly id: CapabilityId;
readonly meteringUnit: string;
}
/** Known capabilities and their metering units. Code-level registry. */
export const CAPABILITIES: Readonly<Record<CapabilityId, CapabilityDescriptor>> = {
pdf_to_md_bundle: { id: "pdf_to_md_bundle", meteringUnit: "pages" },
audio_video_to_text: { id: "audio_video_to_text", meteringUnit: "audio_seconds" },
};
/** Input passed to a capability adapter invocation. */
export interface CapabilityInvocationInput {
readonly runId: string;
readonly organizationId: string;
readonly projectId: string;
/** Absolute workspace dir of the run's project (ADR-0018 surface root). */
readonly workspaceDir: string;
/** Workspace-relative path to the input file (PDF, audio, …). */
readonly inputPath: string;
/** Workspace-relative directory to write outputs into. Created if absent. */
readonly outputDir: string;
/** Prisma client for UsageFact writes. */
readonly prisma: PrismaClient;
}
/** A successfully produced output artifact (file written into workspace). */
export interface CapabilityOutputArtifact {
/** Workspace-relative path of the written artifact. */
readonly path: string;
readonly kind: "markdown" | "image" | "metadata" | "other";
}
/** Consumption recorded for one invocation (written to UsageFact). */
export interface CapabilityConsumption {
/** The backing service provider id (e.g. "mineru", "openai_whisper"). */
readonly provider: string;
/** Model id if the service reports one; null for non-model services. */
readonly model: string | null;
readonly inputTokens: number | null;
readonly outputTokens: number | null;
/** Non-token meter (page count, audio seconds). */
readonly quantity: number;
readonly unit: string;
/** USD cost if the service reported one; null = unknown (ADR-0022). */
readonly costUsd: number | null;
/** External request id for reconciliation. */
readonly correlationId: string | null;
}
/** Result of a successful capability invocation. */
export interface CapabilityInvocationResult {
readonly artifacts: readonly CapabilityOutputArtifact[];
readonly consumption: CapabilityConsumption;
}
/** A capability adapter: resolves credentials, calls the service, writes output. */
export interface CapabilityAdapter {
readonly capabilityId: CapabilityId;
invoke(input: CapabilityInvocationInput): Promise<CapabilityInvocationResult>;
}
/** Decrypted capability credential (CapabilitySecretPayloadV1, ADR-0027). */
export interface CapabilitySecretPayload {
readonly schemaVersion: 1;
readonly baseUrl: string;
readonly apiToken: string;
readonly projectId: string | null;
}
/** Thrown when an org has no ACTIVE capability connection (fail-closed, ADR-0024). */
export class CapabilityConnectionUnavailable extends Error {
constructor(readonly capabilityId: string, readonly organizationId: string) {
super(`no ACTIVE capability connection for ${capabilityId} in org ${organizationId}`);
this.name = "CapabilityConnectionUnavailable";
}
}
/** Prisma transaction client type alias (for resolver signatures). */
export type TxClient = Prisma.TransactionClient;
@@ -0,0 +1,285 @@
import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { prisma, resetDb, seedTestOrganization, testSecretEnvelope, DEFAULT_ORG_ID } from "./helpers.js";
import { createPdfToMdBundleAdapter, CapabilityPathEscape } from "../../src/capability/pdfToMdBundle.js";
import { MineruClientError, type MineruClient, type MineruParseResult } from "../../src/capability/mineruClient.js";
import type { CapabilitySecretPayload } from "../../src/capability/types.js";
const CAPABILITY_ID = "pdf_to_md_bundle";
const PROVIDER_ID = "mineru";
/**
* ADR-0027: pdf_to_md_bundle adapter contract tests. Proves:
* - workspace containment (input + output confined to run workspace)
* - fail-closed credential resolution (no ACTIVE connection → error)
* - UsageFact attribution with non-token meter (pages), costSource rules
* - outputs (md + images) written into workspace
* - MinerU client errors propagate
* The MineruClient is mocked; the prisma + envelope + filesystem are real.
*/
describe("pdf_to_md_bundle capability adapter (ADR-0027)", () => {
let workspaceRoot: string;
let runId: string;
beforeEach(async () => {
await resetDb();
await seedTestOrganization();
workspaceRoot = await mkdtemp(join(tmpdir(), "cph-cap-"));
runId = "run-cap-test";
// AgentRun is required for the UsageFact FK.
await prisma.project.create({
data: {
id: "proj-cap",
organizationId: DEFAULT_ORG_ID,
name: "Cap Test",
workspaceDir: workspaceRoot,
},
});
await prisma.agentRun.create({
data: {
id: runId,
projectId: "proj-cap",
entrypoint: "FEISHU",
provider: "openrouter",
model: "mock-model",
status: "ACTIVE",
prompt: "convert this pdf",
metadata: {},
},
});
});
afterEach(async () => {
await rm(workspaceRoot, { recursive: true, force: true }).catch(() => {});
});
async function seedActiveCapabilityConnection(): Promise<void> {
const payload: CapabilitySecretPayload = {
schemaVersion: 1,
baseUrl: "https://mineru.test/api",
apiToken: "mineru-secret-token",
projectId: null,
};
const connection = await prisma.organizationCapabilityConnection.create({
data: {
id: "cap-conn-1",
organizationId: DEFAULT_ORG_ID,
capabilityId: CAPABILITY_ID,
status: "ACTIVE",
activatedAt: new Date(),
},
});
const envelope = testSecretEnvelope.encryptJson(
{
purpose: "capability",
organizationId: DEFAULT_ORG_ID,
connectionId: connection.id,
secretVersionId: "cap-sv-1",
},
payload,
);
await prisma.capabilityCredentialVersion.create({
data: {
id: "cap-sv-1",
connectionId: connection.id,
version: 1,
envelope: envelope as object,
keyId: envelope.keyId,
},
});
await prisma.organizationCapabilityConnection.update({
where: { id: connection.id },
data: { activeSecretVersionId: "cap-sv-1" },
});
}
function mockMineruClient(result: Partial<MineruParseResult> = {}): MineruClient {
const full: MineruParseResult = {
markdown: "# Parsed Document\n\nHello world.\n\n![fig](page_1_fig_0.jpg)\n",
images: [
{ filename: "page_1_fig_0.jpg", data: new Uint8Array([0xff, 0xd8, 0xff, 0xe0]) },
],
pageCount: 3,
costUsd: 0.015,
requestId: "mineru-task-abc",
...result,
};
return {
parse: vi.fn(async (): Promise<MineruParseResult> => full),
};
}
function makeAdapter(client: MineruClient) {
return createPdfToMdBundleAdapter({
secrets: testSecretEnvelope,
client,
prisma,
});
}
async function seedInputPdf(name: string = "input.pdf"): Promise<string> {
const dir = join(workspaceRoot, "sources");
await mkdir(dir, { recursive: true });
const path = join(dir, name);
await writeFile(path, "%PDF-1.4 fake pdf bytes");
return join("sources", name);
}
it("writes md + images into the workspace and records a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient();
const adapter = makeAdapter(client);
const result = await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
// Outputs landed in workspace.
const md = await readFile(join(workspaceRoot, "output", "document.md"), "utf8");
expect(md).toContain("# Parsed Document");
expect(result.artifacts.some((a) => a.kind === "markdown" && a.path === "output/document.md")).toBe(true);
expect(result.artifacts.some((a) => a.kind === "image" && a.path === "output/page_1_fig_0.jpg")).toBe(true);
// Client received the decrypted credential, not the env.
const call = (client.parse as ReturnType<typeof vi.fn>).mock.calls[0]?.[0] as CapabilitySecretPayload;
expect(call.apiToken).toBe("mineru-secret-token");
expect(call.baseUrl).toBe("https://mineru.test/api");
// UsageFact written with non-token meter and provider_reported cost.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(1);
const fact = facts[0]!;
expect(fact.kind).toBe("external_capability");
expect(fact.provider).toBe(PROVIDER_ID);
expect(fact.capabilityId).toBe(CAPABILITY_ID);
expect(Number(fact.quantity)).toBe(3);
expect(fact.unit).toBe("pages");
expect(Number(fact.costUsd!)).toBeCloseTo(0.015, 8);
expect(fact.costSource).toBe("provider_reported");
expect(fact.correlationId).toBe("mineru-task-abc");
});
it("writes UsageFact with costSource=unknown when MinerU reports no cost", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ costUsd: null, requestId: null });
const adapter = makeAdapter(client);
await adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
});
const fact = (await prisma.usageFact.findFirstOrThrow({ where: { runId } }));
expect(fact.costUsd).toBeNull();
expect(fact.costSource).toBe("unknown");
// quantity still recorded — missing cost ≠ zero consumption (ADR-0022).
expect(Number(fact.quantity)).toBe(3);
});
it("fails closed when the org has no ACTIVE capability connection", async () => {
// No connection seeded.
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/no ACTIVE capability connection/);
// No UsageFact written for a failed resolution.
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects an input path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath: "../../../etc/passwd",
outputDir: "output",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("rejects an output path that escapes the workspace", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const adapter = makeAdapter(mockMineruClient());
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "../../outside",
prisma,
})).rejects.toBeInstanceOf(CapabilityPathEscape);
});
it("propagates MinerU client errors without writing a UsageFact", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client: MineruClient = {
parse: vi.fn(async (): Promise<MineruParseResult> => {
throw new MineruClientError("upstream 502", "mineru_rejected", 502);
}),
};
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/upstream 502/);
const facts = await prisma.usageFact.findMany({ where: { runId } });
expect(facts).toHaveLength(0);
});
it("rejects empty markdown output as a parse failure", async () => {
await seedActiveCapabilityConnection();
const inputPath = await seedInputPdf();
const client = mockMineruClient({ markdown: "" });
const adapter = makeAdapter(client);
await expect(adapter.invoke({
runId,
organizationId: DEFAULT_ORG_ID,
projectId: "proj-cap",
workspaceDir: workspaceRoot,
inputPath,
outputDir: "output",
prisma,
})).rejects.toThrow(/empty markdown/);
});
});
+5 -1
View File
@@ -11,6 +11,7 @@ import Spec.System.Agent.AgentRole
import Spec.System.Agent.Memory
import Spec.System.Agent.AgentSurface
import Spec.System.Agent.Usage
import Spec.System.Agent.Capability
import Spec.System.Lock
import Spec.System.Permission
import Spec.System.PermissionGrant
@@ -43,11 +44,14 @@ likec4 已画出结构;这里补语义:
- `Usage` —— Run 内 append-only 用量计量事实账本(ADR-0026);主模型 completion、
外部能力调用、代理网关旁路共用同一账本。fact 不持锁、不跨 run;`costUsd = none`
表示未知而非零(ADR-0022)。Run 上的 cost/token 标量是其 rollup cache。
- `Capability` —— 外部能力(PDF→MD、音视频→文本等)注册与调用(ADR-0027);
org-scoped 凭证连接复用 ADR-0024 信封但独立于 model-provider;调用是 Run 内副作用,
产物落 workspace,消耗记 UsageFact。capability credential 不进 Agent 进程。
- `Permission` —— read⊂edit⊂manage 角色体系、能力推导、单调性;force-release 在格外。
- `PermissionGrant` —— grant(resource×principal×role)与 settings(六 policy 旋钮)
(ADR-0004);组合规则 OPEN。
- `Audit` —— customer Project/Run 审计从简(内容 OPEN);Platform Audit 由
`PlatformAdministration` 独立承载。
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0026
标识符见 `Spec.Prelude`。决策出处:ADR-0001..0004, 0018, 0020..0027
-/
+104
View File
@@ -0,0 +1,104 @@
import Spec.Prelude
import Spec.System.Agent.Run
import Spec.System.Agent.Usage
/-!
# Capability —— 外部能力注册与调用(ADR-0027)
`AgentRun` 内可能调用**外部能力**:PDF→MD bundle、音视频→文本、OCR 等。这些能力
不是主 agent loop 的模型 completion,不持项目锁,不占 session 语义(ADR-0026 已拒绝
nested Run)。它们是 Run 内的副作用:读 workspace 输入、调外部服务、产物落 workspace、
写一条 `UsageFact`。
本模块 pin 三件事:
1. **ExternalCapability** —— 平台注册的、org 启用的文档/媒体转换服务,由稳定
`capabilityId` 标识。
2. **CapabilityConnection** —— org-scoped 凭证连接,复用 ADR-0024 信封机制但独立于
model-provider connection。只有 `active` connection 可被 resolver 使用。
3. **CapabilityInvocation 良构** —— 调用的输入/输出必须落在 run 的 workspace 内
(ADR-0018 `AgentSurface`),且调用产生的消耗必须以 `UsageFact` 记录。
凭证不经 Agent 子进程:Agent 只通过 capability adapter 间接调用,adapter 在 Hub 侧
解析 org-scoped 凭证并调用外部服务(ADR-0024 resolver boundary)。这与 model-provider
的 loopback proxy 是不同的机制——capability 调用不走 agent 的网络面,走 Hub 直连。
不变式(`PINNED`, ADR-0027):
- **凭证隔离** —— capability credential 不进 Agent 进程环境;adapter 在 Hub 侧解析。
- **workspace 边界** —— 输入路径与输出目录都必须落在 run 的 workspace 内(ADR-0018)。
- **必记 fact** —— 一次成功调用必须产生 ≥1 条 `UsageFact`(`kind = externalCapability`),
即使 `costUsd = none`(unknown ≠ 零,ADR-0022/0026)。
`CapabilityInvocation` 作为一等持久记录(status/retries/partial output)在 pilot 不做;
`UsageFact` 的 `capabilityId + correlationId` 是唯一可追溯痕迹(ADR-0027 Deferred)。
-/
namespace Spec.System.Agent
variable (I : Identifiers) (Path : Type)
/-- 外部能力的输入种类(`PINNED`, ADR-0027)。集合 `OPEN`——新增 kind 须 surface。 -/
inductive CapabilityInputKind where
/-- PDF 文档(`PINNED`)。 -/
| pdf
/-- 图片(`PINNED`)。 -/
| image
/-- 音频(`PINNED`)。 -/
| audio
/-- 视频(`PINNED`)。 -/
| video
/-- 外部能力(`PINNED`, ADR-0027)。平台注册的文档/媒体转换服务,由 org 启用。
一个 capability 由稳定 `capabilityId` 标识(如 `pdf_to_md_bundle`),声明接受的输入
种类与计量单位。org 通过 `CapabilityConnection` 启用它;adapter 是平台侧实现。 -/
structure ExternalCapability where
/-- 稳定能力标识(`PINNED`;如 `pdf_to_md_bundle`、`audio_video_to_text`)。 -/
capabilityId : String
/-- 接受的输入种类(`PINNED`, ADR-0027)。 -/
inputKind : CapabilityInputKind
/-- 计量单位(`OPEN`;如 `"pages"`、`"audio_seconds"`)。与 UsageFact.unit 对齐。 -/
meteringUnit : String
/-- capability connection 的运行态(`PINNED`, ADR-0024/0027):与 model-provider /
Feishu connection 同构,只有 `active` 可被 resolver 使用。 -/
inductive CapabilityConnectionStatus where
| draft
| active
| disabled
/-- Organization 的外部能力凭证连接(`PINNED`, ADR-0027)。复用 ADR-0024 信封机制
但独立于 model-provider connection:capability 服务(如 MinerU、Whisper)有自己的 auth
形状与 readiness probe,不走 OpenRouter `/v1/models`。唯一性键为
`(organization, capabilityId)`。 -/
structure OrganizationCapabilityConnection where
/-- connection 所属 organization(`PINNED`, ADR-0027)。 -/
organization : I.OrganizationId
/-- 该 connection 启用的 capability(`PINNED`, ADR-0027)。 -/
capability : ExternalCapability
/-- 运行态(`PINNED`, ADR-0024/0027)。 -/
status : CapabilityConnectionStatus
/-- 一次 capability 调用的良构约束(`PINNED`, ADR-0027)。输入路径与输出目录都必须落在
发起 run 的 workspace 内(ADR-0018 `AgentSurface`);`runWorkspace` 与 `pathWithin`
由平台提供(表示 `OPEN`)。逃逸即越权,拒绝。 -/
structure CapabilityInvocation where
/-- 发起调用的 run(`PINNED`;调用是 Run 内副作用,不跨 run,ADR-0026/0027)。 -/
run : I.RunId
/-- 被调用的 capability(`PINNED`)。 -/
capability : ExternalCapability
/-- 输入路径(workspace 内,`PINNED`, ADR-0018)。 -/
inputPath : Path
/-- 输出目录(workspace 内,`PINNED`, ADR-0018)。 -/
outputDir : Path
/-- capability 调用良构:输入与输出路径都落在 run 的 workspace 内(`PINNED`,
ADR-0018/0027)。这是 `AgentFileOp.Authorized` 在 capability 调用上的对应物。 -/
def CapabilityInvocation.Authorized
(inv : CapabilityInvocation I Path)
(runWorkspace : I.RunId Option Path)
(pathWithin : Path Path Prop) : Prop :=
w, runWorkspace inv.run = some w pathWithin inv.inputPath w pathWithin inv.outputDir w
end Spec.System.Agent