Files
PaperClipAI/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts
T
DottaandPaperclip eb049aebf2 feat(skills): let agents update company skills safely (#15049)
## Thinking Path

> - Paperclip is an open source control plane for AI-agent companies.
> - Company skills give agents reusable work instructions.
> - Skill Studio can edit skill files and save version history.
> - Agents can create a skill, but they do not have a first-class update
tool.
> - An agent update needs a version check and safe retry behavior to
prevent lost edits.
> - This pull request adds `update_skill` through the existing company
skill file API.
> - The change keeps company policy, version history, and audit records
in one path.

## Linked Issues or Issue Description

**Subsystem affected**

Cross-cutting: the server API, shared validation, and runner tool
catalog.

**Problem or motivation**

An agent can create a company skill but cannot update its `SKILL.md`
through a first-class tool. An unguarded retry can also create duplicate
versions or overwrite a newer edit.

**Proposed solution**

Add `update_skill` with a required current version ID and a retry key.
Route it through the existing skill file API. Reject stale versions and
changed-input retries. Save the version and audit event together.

**Alternatives considered**

A separate write endpoint would duplicate the Skill Studio mutation path
and policy checks. This PR reuses that path instead.

**Roadmap alignment**

This work extends Skills Manager and Skill Studio, which are listed in
`ROADMAP.md`.

**Additional context**

The tool accepts a complete `SKILL.md`, not a partial patch. Callers
must read the current version before they edit it.

## What Changed

- Add optional version and retry fields to the existing skill file
update contract.
- Add a guarded API update with a stable retry receipt and attributed
audit event.
- Add `update_skill` to native and semantic runner tool catalogs, with
mode and policy gates.
- Add unit, integration, protocol, and semantic-tool regression
coverage.
- Document agent use and extend the OpenAPI request contract.

## Verification

- Focused tests and direct server and runner TypeScript checks passed
before this PR.
- `git diff --check` passed after the rebase onto `master`.
- CI passed on the latest PR head, including the full test matrix,
typecheck, and build. Local full typecheck and build stopped because
`cargo` is not installed. The local full test run ended without a
verdict.
- No dedicated end-to-end eval scenario was added or run. The protocol
coverage and semantic-tool test cover the new action deterministically.
- Reviewers can read a skill version, call `update_skill`, repeat the
same key, then try a stale version and a changed-input key. Only the
first edit must create a new version.

## Risks

- File writes and database transactions must stay in sync when a write
fails. The integration tests cover failed writes and retry behavior, but
CI must verify them on the PR head.
- Existing Skill Studio callers do not send the new optional
coordination fields. Their request shape remains valid.

## Model Used

- OpenAI Codex CLI assisted with this change. The runner did not expose
the exact model ID or context window. The agent used code execution and
repository tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details; exact model ID and context window were not exposed)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (focused tests; full suite
is pending CI)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green (53 pass, 4 skip on the latest
head)
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-03 12:37:55 -05:00

1761 lines
66 KiB
TypeScript

import type {
CapabilityCommandEnvelope,
CapabilityJsonValue,
CapabilityMockControlPlanePort,
CapabilitySemanticToolRuntimeSnapshot,
CapabilitySemanticCommand,
} from "../mock-core/capability-control-plane-types.js";
import { capabilitySemanticTool } from "./capability-semantic-tool-catalog.js";
import { CapabilityToolAuthorizationEngine } from "./capability-tool-authorization.js";
import type {
CapabilityAuthorizationRecord,
CapabilityJsonSchema,
CapabilityModelToolDelivery,
CapabilityModelToolInvocationResult,
CapabilityModelToolSuccess,
CapabilityPolicyDenial,
CapabilityScenarioToolPolicy,
CapabilitySemanticToolDescriptor,
CapabilityToolAuthorizationContext,
CapabilityToolInvocation,
CapabilityToolInvocationResult,
CapabilityToolSuccess,
CapabilityVisibleToolSet,
} from "./capability-semantic-tool-types.js";
export interface CapabilitySemanticToolRuntimeOptions {
adapter: CapabilityMockControlPlanePort;
runId: string;
scenarioGrants?: string[];
policy?: CapabilityScenarioToolPolicy;
resolveSecretValue?: (name: string) => Promise<string | null> | string | null;
/**
* Optional trusted backend receipt lookup for an expired extension. The
* package-local mock runtime falls back to the exact prepared receipt stored
* before execution. Legacy records without a prepared receipt are recoverable
* only for fixture computations whose output is independent of mutable
* adapter state. State-derived exports require an exact prepared receipt or
* a trusted backend lookup so a retry cannot substitute a newer projection
* for the value observed by the original execution.
*/
resolveExpiredExtensionReceipt?: (input: {
operationId: string;
input: Record<string, CapabilityJsonValue>;
idempotencyKey: string;
}) => Promise<Pick<CapabilityExpiredExtensionReceipt, "value" | "entityRefs"> | null> |
Pick<CapabilityExpiredExtensionReceipt, "value" | "entityRefs"> | null;
now?: () => number;
}
export interface CapabilityExpiredExtensionReceipt {
operationId: string;
input: Record<string, CapabilityJsonValue>;
idempotencyKey: string;
value: CapabilityJsonValue;
entityRefs?: string[];
}
interface ExtensionExecution {
value: CapabilityJsonValue;
commandResult: CapabilityToolSuccess["commandResult"];
entityRefs: string[];
}
interface ExtensionIdempotencyRecord {
input: string;
execution: Promise<{ resultId: string; value: ExtensionExecution }> | null;
ownerId: string | null;
leaseExpiresAtMs: number;
leaseHeartbeat: ReturnType<typeof setInterval> | null;
cleanupRetry: ReturnType<typeof setTimeout> | null;
completed?: { resultId: string; value: ExtensionExecution };
recoveryFailure?: "idempotency_receipt_unavailable";
}
interface CapabilitySemanticRuntimeState {
extensionIdempotency: Map<string, ExtensionIdempotencyRecord>;
operationResults: Map<string, CapabilityJsonValue>;
resultSequence: number;
}
const RUNTIME_STATE_BY_ADAPTER = new WeakMap<
CapabilityMockControlPlanePort,
Map<string, CapabilitySemanticRuntimeState>
>();
const EXTENSION_EXECUTION_LEASE_MS = 30_000;
const EXTENSION_EXECUTION_HEARTBEAT_MS = Math.floor(
EXTENSION_EXECUTION_LEASE_MS / 3,
);
const RUNTIME_PERSIST_CAS_ATTEMPTS = 8;
const RUNTIME_COMPLETION_CAS_ATTEMPTS = 64;
const REPLAY_SAFE_LEGACY_MOCK_EXTENSIONS = new Set([
"discovery.projects",
"discovery.goals",
"cases.list",
"routines.list",
"company_skills.list",
"secrets.metadata",
"company_skills.sync",
"routines.manage",
"cases.upsert",
"company.admin",
"test.generic_api",
]);
export class CapabilitySemanticToolRuntime {
readonly #adapter: CapabilityMockControlPlanePort;
readonly #runId: string;
readonly #scenarioGrants: string[];
readonly #policy: CapabilityScenarioToolPolicy | undefined;
readonly #resolveSecretValue: CapabilitySemanticToolRuntimeOptions["resolveSecretValue"];
readonly #resolveExpiredExtensionReceipt: CapabilitySemanticToolRuntimeOptions["resolveExpiredExtensionReceipt"];
readonly #now: () => number;
readonly #authorization = new CapabilityToolAuthorizationEngine();
readonly #state: CapabilitySemanticRuntimeState;
constructor(options: CapabilitySemanticToolRuntimeOptions) {
this.#adapter = options.adapter;
this.#runId = options.runId;
this.#scenarioGrants = [...new Set(options.scenarioGrants ?? [])].sort();
this.#policy = options.policy === undefined ? undefined : structuredClone(options.policy);
this.#resolveSecretValue = options.resolveSecretValue;
this.#resolveExpiredExtensionReceipt = options.resolveExpiredExtensionReceipt;
this.#now = options.now ?? Date.now;
let adapterState = RUNTIME_STATE_BY_ADAPTER.get(options.adapter);
if (adapterState === undefined) {
adapterState = new Map();
RUNTIME_STATE_BY_ADAPTER.set(options.adapter, adapterState);
}
const existingState = adapterState.get(options.runId);
if (existingState !== undefined) this.#state = existingState;
else {
this.#state = this.#restoreState(
options.adapter.loadSemanticToolRuntime(options.runId),
);
adapterState.set(options.runId, this.#state);
}
}
visibleTools(): CapabilityVisibleToolSet {
return this.#authorization.computeVisibleTools(this.#context());
}
authorizationRecords(): readonly CapabilityAuthorizationRecord[] {
return this.#authorization.records();
}
/**
* Publishes the exact observed result of an expired extension that entered
* execution. This is a trusted recovery boundary: normal tool invocations
* cannot guess, replay, or replace an ambiguous effect after its executor
* exits.
*/
reconcileExpiredExtensionReceipt(
receipt: CapabilityExpiredExtensionReceipt,
): { resultId: string } {
const descriptor = capabilitySemanticTool(receipt.operationId);
if (
descriptor?.mockCommandMapping.kind !== "mock_extension" ||
descriptor.idempotency !== "required"
) {
throw new Error("extension receipt reconciliation is not available");
}
const idempotencyKey = receipt.idempotencyKey.trim();
if (idempotencyKey.length === 0) {
throw new Error("extension receipt idempotency key is required");
}
if (receipt.entityRefs?.some((ref) => typeof ref !== "string")) {
throw new Error("extension receipt entity references are invalid");
}
const key = `${this.#runId}:${receipt.operationId}:${idempotencyKey}`;
const input = canonicalJson(receipt.input);
const value = structuredClone(receipt.value);
const entityRefs = [...new Set(receipt.entityRefs ?? [])].sort();
for (let attempt = 0; attempt < RUNTIME_COMPLETION_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find(
(candidate) => candidate.key === key,
);
if (durable === null || extension === undefined || extension.input !== input) {
throw new Error("expired extension receipt does not match durable execution");
}
if (
extension.status !== "pending" &&
extension.status !== "indeterminate"
) {
const durableResult = durable.operationResults[extension.resultId];
if (durableResult === undefined) {
throw new Error("completed extension receipt omitted its operation result");
}
// The original authorized executor may publish while trusted recovery
// is resolving the expired lease. Its durable completion is already
// authoritative for this exact key and canonical input; adopt it
// instead of letting a stale recovery observation replace or reject
// successfully completed work.
this.#state.operationResults.set(
extension.resultId,
structuredClone(durableResult),
);
this.#state.resultSequence = Math.max(
this.#state.resultSequence,
durable.resultSequence,
);
this.#adoptReconciledExtension(
key,
input,
extension.resultId,
structuredClone(extension.execution),
);
return { resultId: extension.resultId };
}
if (
extension.status === "pending" &&
(extension.phase !== "executing" ||
(extension.leaseExpiresAtMs ?? 0) > this.#now())
) {
throw new Error("extension execution lease is still live or unproved");
}
const replacement = structuredClone(durable);
let resultSequence = Math.max(
replacement.resultSequence,
this.#state.resultSequence,
);
let resultId: string;
do {
resultId = `tool-result-${++resultSequence}`;
} while (Object.prototype.hasOwnProperty.call(
replacement.operationResults,
resultId,
));
const execution: ExtensionExecution = {
value,
commandResult: null,
entityRefs,
};
replacement.resultSequence = resultSequence;
replacement.operationResults[resultId] = structuredClone(value);
replacement.extensions = replacement.extensions.map((candidate) =>
candidate.key === key
? {
key,
input,
status: "completed" as const,
resultId,
execution: structuredClone(execution),
}
: candidate,
);
if (!this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) continue;
this.#state.resultSequence = resultSequence;
this.#adoptReconciledExtension(key, input, resultId, execution);
return { resultId };
}
throw new Error("durable extension changed during receipt reconciliation");
}
/** Returns only the result shape allowed to cross observable boundaries. */
async invoke(invocation: CapabilityToolInvocation): Promise<CapabilityToolInvocationResult> {
return (await this.#invoke(invocation, false)).observableResult;
}
/**
* Invokes a tool for provider delivery. The raw result remains in a capsule
* that serializes and clones as the redacted observable result.
*/
async invokeForModel(invocation: CapabilityToolInvocation): Promise<CapabilityModelToolDelivery> {
const result = await this.#invoke(invocation, true);
const modelResult = result.modelResult !== undefined
? result.modelResult
: !result.observableResult.ok
? result.observableResult
: undefined;
if (modelResult === undefined) throw new Error("model delivery result was not produced");
return new CapabilityModelToolDeliveryImpl(
result.observableResult,
modelResult,
);
}
async #invoke(
invocation: CapabilityToolInvocation,
includeModelResult: boolean,
): Promise<{
observableResult: CapabilityToolInvocationResult;
modelResult?: CapabilityModelToolInvocationResult;
}> {
const context = this.#context();
const authorization = this.#authorization.authorizeInvocation(
invocation.operationId,
invocation.input,
context,
);
if (authorization.outcome !== "allowed") {
return {
observableResult: this.#denial(
invocation.operationId,
authorization,
authorization.outcome === "absent" ? "operation_absent" : "policy_denied",
),
};
}
const descriptor = capabilitySemanticTool(invocation.operationId)!;
const validationIssues = validateJsonSchema(descriptor.inputSchema, invocation.input);
if (validationIssues.length > 0) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"input_schema_invalid",
);
return { observableResult: this.#denial(invocation.operationId, denied, "input_invalid") };
}
if (descriptor.idempotency === "required" && !invocation.idempotencyKey?.trim()) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_key_required",
);
return { observableResult: this.#denial(invocation.operationId, denied, "input_invalid") };
}
if (invocation.operationId === "decide_approval" && this.#isSelfApproval(invocation.input)) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"self_approval_conflict",
);
return { observableResult: this.#denial(invocation.operationId, denied, "policy_denied") };
}
const beforeRevision = this.#adapter.snapshot().revision;
try {
const extensionIdempotencyKey = descriptor.mockCommandMapping.kind === "mock_extension" &&
descriptor.idempotency === "required"
? `${this.#runId}:${invocation.operationId}:${invocation.idempotencyKey!.trim()}`
: null;
const canonicalInput = extensionIdempotencyKey === null
? null
: canonicalJson(invocation.input);
const cachedExtension = extensionIdempotencyKey === null
? undefined
: this.#state.extensionIdempotency.get(extensionIdempotencyKey);
if (cachedExtension !== undefined && cachedExtension.input !== canonicalInput) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_key_conflict",
);
return { observableResult: this.#denial(invocation.operationId, denied, "input_invalid") };
}
let execution: ExtensionExecution;
let resultId: string | null;
let extensionRecord: ExtensionIdempotencyRecord | null = null;
if (extensionIdempotencyKey !== null) {
let idempotencyRecord = cachedExtension;
if (idempotencyRecord === undefined) {
idempotencyRecord = {
input: canonicalInput!,
execution: null,
ownerId: null,
leaseExpiresAtMs: 0,
leaseHeartbeat: null,
cleanupRetry: null,
};
this.#state.extensionIdempotency.set(
extensionIdempotencyKey,
idempotencyRecord,
);
let executionStarted = false;
try {
executionStarted = this.#startExtensionExecution(
extensionIdempotencyKey,
idempotencyRecord,
descriptor,
invocation,
);
} catch (error) {
// Acquisition has not handed an execution promise to this record.
// Do not let a transient store failure strand the local key; any
// durable lease that committed before the failure still gates the
// next attempt through the normal recovery path.
this.#state.extensionIdempotency.delete(extensionIdempotencyKey);
throw error;
}
if (!executionStarted) {
this.#state.extensionIdempotency.delete(extensionIdempotencyKey);
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_recovery_in_flight",
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
}
if (idempotencyRecord.execution === null) {
let durableState = this.#adapter.loadSemanticToolRuntime(this.#runId);
let durableExtension = durableState?.extensions.find(
(extension) => extension.key === extensionIdempotencyKey,
);
if (durableState === null || durableExtension === undefined) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_recovery_in_flight",
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
if (durableExtension.status === "indeterminate") {
const recovered = await this.#resolveExpiredExtension(invocation);
if (!recovered) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
durableExtension.reason,
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
durableState = this.#adapter.loadSemanticToolRuntime(this.#runId);
durableExtension = durableState?.extensions.find(
(extension) => extension.key === extensionIdempotencyKey,
);
if (durableState === null || durableExtension?.status !== "completed") {
throw new Error("indeterminate extension recovery did not publish a durable receipt");
}
}
if (durableExtension.status === "pending") {
if ((durableExtension.leaseExpiresAtMs ?? 0) > this.#now()) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_recovery_in_flight",
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
if (
durableExtension.phase === "executing"
) {
const recovered = await this.#resolveExpiredExtension(
invocation,
);
if (!recovered) {
this.#markExpiredExtensionIndeterminate(
extensionIdempotencyKey,
idempotencyRecord,
);
}
durableState = this.#adapter.loadSemanticToolRuntime(this.#runId);
durableExtension = durableState?.extensions.find(
(extension) => extension.key === extensionIdempotencyKey,
);
if (durableExtension?.status === "indeterminate") {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
durableExtension.reason,
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
if (durableState === null || durableExtension?.status !== "completed") {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_recovery_in_flight",
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
}
}
if (durableExtension.status === "pending") {
if (!this.#startExtensionExecution(
extensionIdempotencyKey,
idempotencyRecord,
descriptor,
invocation,
)) {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"idempotency_recovery_in_flight",
);
return {
observableResult: this.#denial(
invocation.operationId,
denied,
"operation_unsupported",
),
};
}
} else {
if (durableExtension.input !== idempotencyRecord.input) {
throw new Error("durable extension input changed during recovery");
}
const completed = {
resultId: durableExtension.resultId,
value: structuredClone(durableExtension.execution),
};
const durableResult = durableState.operationResults[completed.resultId];
if (durableResult === undefined) {
throw new Error("durable extension receipt omitted its operation result");
}
idempotencyRecord.completed = structuredClone(completed);
idempotencyRecord.execution = Promise.resolve(structuredClone(completed));
idempotencyRecord.ownerId = null;
idempotencyRecord.leaseExpiresAtMs = 0;
this.#state.operationResults.set(
completed.resultId,
structuredClone(durableResult),
);
this.#state.resultSequence = Math.max(
this.#state.resultSequence,
durableState.resultSequence,
);
}
}
if (idempotencyRecord.execution === null) {
throw new Error("durable extension execution was not acquired");
}
const completed = await idempotencyRecord.execution;
execution = structuredClone(completed.value);
resultId = completed.resultId;
extensionRecord = idempotencyRecord;
} else {
execution = await this.#execute(descriptor, invocation);
resultId = execution.commandResult?.commandId ?? null;
}
let observableValue = redactForBoundary(descriptor, execution.value, "output");
let observableCommandResult = execution.commandResult === null
? null
: redactForBoundary(descriptor, toJsonValue(execution.commandResult), "output") as CapabilityToolSuccess["commandResult"];
if (
extensionIdempotencyKey !== null &&
extensionRecord !== null &&
extensionRecord.completed === undefined
) {
if (resultId === null) {
throw new Error("durable extension execution omitted its result id");
}
const completed = {
resultId,
value: structuredClone(execution),
};
const published = await this.#completeExtensionExecution(
extensionIdempotencyKey,
extensionRecord,
completed,
observableValue,
);
if (published === null) {
// The extension already ran. Retain and renew its durable ownership
// so another runtime cannot execute it again while this runtime can
// still publish the exact completed receipt on an identical retry.
try {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find(
(candidate) => candidate.key === extensionIdempotencyKey,
);
if (
durable === null ||
extension?.status !== "pending" ||
extension.ownerId !== extensionRecord.ownerId
) {
this.#stopExtensionExecutionLease(extensionRecord);
}
} catch {
// A read failure cannot prove ownership was lost. Keep renewing it.
}
throw new Error("durable extension execution lease was superseded");
}
// A trusted recovery may publish the durable receipt while this
// executor is still completing after heartbeat-store failures. Adopt
// that receipt instead of turning already-completed work into a
// denial; the durable key and canonical input were verified below.
resultId = published.resultId;
execution = structuredClone(published.value);
observableValue = structuredClone(published.observableValue);
observableCommandResult = execution.commandResult === null
? null
: redactForBoundary(
descriptor,
toJsonValue(execution.commandResult),
"output",
) as CapabilityToolSuccess["commandResult"];
completed.resultId = published.resultId;
completed.value = structuredClone(published.value);
this.#stopExtensionExecutionLease(extensionRecord);
extensionRecord.completed = completed;
extensionRecord.execution = Promise.resolve(
structuredClone(extensionRecord.completed),
);
extensionRecord.ownerId = null;
extensionRecord.leaseExpiresAtMs = 0;
}
const finalAuthorization = this.#authorization.attachStateChange(
authorization.sequence,
beforeRevision,
this.#adapter.snapshot().revision,
execution.entityRefs,
);
let sequencedResultPersisted = false;
if (extensionIdempotencyKey === null && resultId === null) {
resultId = this.#persistSequencedOperationResult(observableValue);
sequencedResultPersisted = true;
}
if (resultId === null) throw new Error("semantic tool result id was not allocated");
this.#state.operationResults.set(resultId, structuredClone(observableValue));
if (extensionIdempotencyKey === null && !sequencedResultPersisted) {
this.#persistState();
}
const success: CapabilityToolSuccess = {
schema: "paperclip.capability.tool-result.v1",
ok: true,
operationId: invocation.operationId,
operationResultId: resultId,
value: observableValue,
commandResult: observableCommandResult,
authorization: finalAuthorization,
};
const observableResult = deepFreeze(success);
if (!includeModelResult) return { observableResult };
const modelResult: CapabilityModelToolSuccess = deepFreeze({
schema: "paperclip.capability.model-tool-result.v1",
ok: true,
operationId: invocation.operationId,
operationResultId: resultId,
value: structuredClone(execution.value),
commandResult: execution.commandResult === null ? null : structuredClone(execution.commandResult),
authorization: finalAuthorization,
});
return { observableResult, modelResult };
} catch {
const denied = this.#authorization.denyInvocation(
invocation.operationId,
context,
"mock_operation_rejected",
);
return { observableResult: this.#denial(invocation.operationId, denied, "operation_unsupported") };
}
}
#nextResultId(): string {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
this.#state.resultSequence = Math.max(
this.#state.resultSequence,
durable?.resultSequence ?? 0,
);
return `tool-result-${++this.#state.resultSequence}`;
}
#adoptReconciledExtension(
key: string,
input: string,
resultId: string,
value: ExtensionExecution,
): void {
const completed = { resultId, value: structuredClone(value) };
const existing = this.#state.extensionIdempotency.get(key);
if (existing) this.#stopExtensionExecutionLease(existing);
this.#state.extensionIdempotency.set(key, {
input,
execution: Promise.resolve(structuredClone(completed)),
ownerId: null,
leaseExpiresAtMs: 0,
leaseHeartbeat: null,
cleanupRetry: null,
completed,
});
this.#state.operationResults.set(resultId, structuredClone(value.value));
}
async #resolveExpiredExtension(
invocation: CapabilityToolInvocation,
): Promise<boolean> {
const idempotencyKey = invocation.idempotencyKey?.trim();
if (!idempotencyKey) return false;
const recoveryInput = {
operationId: invocation.operationId,
input: structuredClone(invocation.input) as Record<string, CapabilityJsonValue>,
idempotencyKey,
};
const key = `${this.#runId}:${invocation.operationId}:${idempotencyKey}`;
const extension = this.#adapter
.loadSemanticToolRuntime(this.#runId)
?.extensions.find((candidate) => candidate.key === key);
let observed: Pick<
CapabilityExpiredExtensionReceipt,
"value" | "entityRefs"
> | null =
extension?.status === "pending" &&
extension.phase === "executing" &&
extension.input === canonicalJson(invocation.input) &&
extension.preparedExecution !== undefined
? {
value: structuredClone(extension.preparedExecution.value),
entityRefs: [...extension.preparedExecution.entityRefs],
}
: null;
if (observed === null && this.#resolveExpiredExtensionReceipt !== undefined) {
observed = await this.#resolveExpiredExtensionReceipt(recoveryInput);
}
if (observed === null) {
const descriptor = capabilitySemanticTool(invocation.operationId);
if (
descriptor?.mockCommandMapping.kind === "mock_extension" &&
descriptor.idempotency === "required" &&
REPLAY_SAFE_LEGACY_MOCK_EXTENSIONS.has(
descriptor.mockCommandMapping.extension,
)
) {
// Snapshots written before preparedExecution existed can still be
// recovered without replaying an external mutation. Every idempotent
// extension in this explicit allowlist is a pure fixture computation.
// Secret reads and mutable state-derived exports are deliberately
// excluded: neither can recover the exact value observed by the lost
// executor without a prepared or authoritative receipt.
const reconstructed = this.#prepareBuiltInExtensionExecution(
descriptor,
asObject(invocation.input),
);
observed = {
value: structuredClone(reconstructed.value),
entityRefs: [...reconstructed.entityRefs],
};
}
}
if (observed === null) return false;
this.reconcileExpiredExtensionReceipt({
...recoveryInput,
value: structuredClone(observed.value),
...(observed.entityRefs === undefined
? {}
: { entityRefs: [...observed.entityRefs] }),
});
return true;
}
#markExpiredExtensionIndeterminate(
key: string,
record: ExtensionIdempotencyRecord,
): void {
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find((candidate) => candidate.key === key);
if (
durable === null ||
extension === undefined ||
extension.status !== "pending" ||
extension.input !== record.input ||
extension.phase !== "executing" ||
(extension.leaseExpiresAtMs ?? 0) > this.#now()
) {
return;
}
const replacement = structuredClone(durable);
replacement.extensions = replacement.extensions.map((candidate) =>
candidate.key === key
? {
key,
input: record.input,
status: "indeterminate" as const,
reason: "idempotency_receipt_unavailable" as const,
}
: candidate,
);
if (!this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) continue;
this.#stopExtensionExecutionLease(record);
record.ownerId = null;
record.leaseExpiresAtMs = 0;
record.recoveryFailure = "idempotency_receipt_unavailable";
return;
}
}
#startExtensionExecution(
key: string,
record: ExtensionIdempotencyRecord,
descriptor: CapabilitySemanticToolDescriptor,
invocation: CapabilityToolInvocation,
): boolean {
const ownerId = globalThis.crypto.randomUUID();
let leaseExpiresAtMs = 0;
let acquired = false;
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const existing = durable?.extensions.find((candidate) => candidate.key === key);
if (
existing !== undefined &&
(existing.status !== "pending" ||
existing.input !== record.input ||
existing.phase === "executing" ||
(existing.leaseExpiresAtMs ?? 0) > this.#now())
) {
return false;
}
leaseExpiresAtMs = this.#now() + EXTENSION_EXECUTION_LEASE_MS;
const replacement: CapabilitySemanticToolRuntimeSnapshot = durable === null
? {
schema: "paperclip.capability.semantic-tool-runtime.v1",
resultSequence: this.#state.resultSequence,
operationResults: Object.fromEntries(this.#state.operationResults),
extensions: [],
}
: structuredClone(durable);
const pending = {
key,
input: record.input,
status: "pending" as const,
ownerId,
leaseExpiresAtMs,
phase: "reserved" as const,
};
const existingIndex = replacement.extensions.findIndex(
(candidate) => candidate.key === key,
);
if (existingIndex === -1) replacement.extensions.push(pending);
else replacement.extensions[existingIndex] = pending;
if (this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) {
this.#state.resultSequence = Math.max(
this.#state.resultSequence,
replacement.resultSequence,
);
acquired = true;
break;
}
}
if (!acquired) return false;
if (record.cleanupRetry !== null) clearTimeout(record.cleanupRetry);
record.cleanupRetry = null;
record.ownerId = ownerId;
record.leaseExpiresAtMs = leaseExpiresAtMs;
// Deterministic package-local mock extensions can persist their exact
// result before crossing the executing boundary. Async/custom effects do
// not guess a receipt and retain the external reconciliation boundary.
const preparedExecution = this.#prepareBuiltInExtensionExecution(
descriptor,
asObject(invocation.input),
);
let executionStarted = false;
try {
executionStarted = this.#markExtensionExecutionStarted(
key,
record,
preparedExecution,
);
} catch {
executionStarted = false;
}
if (!executionStarted) {
this.#scheduleFailedExtensionCleanup(key, record);
return false;
}
const execution = Promise.resolve(structuredClone(preparedExecution)).then((value) => ({
resultId: value.commandResult?.commandId ?? this.#nextResultId(),
value,
}));
record.execution = execution;
record.leaseHeartbeat = setInterval(() => {
try {
this.#renewExtensionExecutionLease(key, record);
} catch {
// A transient durable-store failure does not prove that ownership was
// lost. Keep the execution alive and retry at the next heartbeat; the
// completion CAS still validates the exact owner before publishing.
}
}, EXTENSION_EXECUTION_HEARTBEAT_MS);
record.leaseHeartbeat.unref();
void execution.catch(() => {
record.execution = null;
this.#scheduleFailedExtensionCleanup(key, record);
});
return true;
}
#scheduleFailedExtensionCleanup(
key: string,
record: ExtensionIdempotencyRecord,
): void {
if (record.cleanupRetry !== null) return;
const failedOwnerId = record.ownerId;
if (failedOwnerId === null) return;
const cleanup = () => {
record.cleanupRetry = null;
if (this.#removeFailedExtensionLease(key, failedOwnerId)) {
if (record.ownerId === failedOwnerId) {
this.#stopExtensionExecutionLease(record);
if (this.#state.extensionIdempotency.get(key) === record) {
this.#state.extensionIdempotency.delete(key);
}
}
return;
}
if (record.ownerId !== failedOwnerId) return;
record.cleanupRetry = setTimeout(cleanup, 1_000);
record.cleanupRetry.unref();
};
cleanup();
}
#removeFailedExtensionLease(
key: string,
failedOwnerId: string,
): boolean {
try {
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const current = this.#adapter.loadSemanticToolRuntime(this.#runId);
const currentExtension = current?.extensions.find(
(candidate) => candidate.key === key,
);
if (
current === null ||
currentExtension?.status !== "pending" ||
currentExtension.ownerId !== failedOwnerId
) {
return true;
}
const withoutFailedLease = structuredClone(current);
withoutFailedLease.extensions = withoutFailedLease.extensions.filter(
(candidate) => candidate.key !== key,
);
if (this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
current,
withoutFailedLease,
)) return true;
}
} catch {
return false;
}
return false;
}
#markExtensionExecutionStarted(
key: string,
record: ExtensionIdempotencyRecord,
preparedExecution: ExtensionExecution | undefined,
): boolean {
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find((candidate) => candidate.key === key);
if (
durable === null ||
extension?.status !== "pending" ||
extension.ownerId !== record.ownerId ||
extension.phase === "executing"
) {
return false;
}
const replacement = structuredClone(durable);
const leaseExpiresAtMs = this.#now() + EXTENSION_EXECUTION_LEASE_MS;
replacement.extensions = replacement.extensions.map((candidate) =>
candidate.key === key
? {
...candidate,
phase: "executing" as const,
leaseExpiresAtMs,
...(preparedExecution === undefined
? {}
: { preparedExecution: structuredClone(preparedExecution) }),
}
: candidate,
);
if (!this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) continue;
record.leaseExpiresAtMs = leaseExpiresAtMs;
return true;
}
return false;
}
#renewExtensionExecutionLease(
key: string,
record: ExtensionIdempotencyRecord,
): boolean {
if (record.ownerId === null) return false;
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find((candidate) => candidate.key === key);
if (durable === null || extension?.status !== "pending" ||
extension.ownerId !== record.ownerId) return false;
const replacement = structuredClone(durable);
const leaseExpiresAtMs = this.#now() + EXTENSION_EXECUTION_LEASE_MS;
replacement.extensions = replacement.extensions.map((candidate) =>
candidate.key === key
? { ...candidate, leaseExpiresAtMs }
: candidate,
);
if (!this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) continue;
record.leaseExpiresAtMs = leaseExpiresAtMs;
return true;
}
return false;
}
async #completeExtensionExecution(
key: string,
record: ExtensionIdempotencyRecord,
completed: { resultId: string; value: ExtensionExecution },
observableValue: CapabilityJsonValue,
): Promise<{
resultId: string;
value: ExtensionExecution;
observableValue: CapabilityJsonValue;
} | null> {
// Execution already happened. Retry only the durable completion merge so
// a lease heartbeat or unrelated snapshot update cannot discard its
// receipt or cause the operation to execute again.
for (let attempt = 0; attempt < RUNTIME_COMPLETION_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const extension = durable?.extensions.find((candidate) => candidate.key === key);
if (durable === null || extension === undefined) return null;
if (extension.status === "completed") {
const durableResult = durable.operationResults[extension.resultId];
return extension.input === record.input && durableResult !== undefined
? {
resultId: extension.resultId,
value: structuredClone(extension.execution),
observableValue: structuredClone(durableResult),
}
: null;
}
if (extension.status !== "pending" || record.ownerId === null ||
extension.ownerId !== record.ownerId) {
return null;
}
const replacement = structuredClone(durable);
const existingResult = replacement.operationResults[completed.resultId];
if (
existingResult !== undefined &&
canonicalJson(existingResult) !== canonicalJson(observableValue)
) {
replacement.resultSequence = Math.max(
replacement.resultSequence,
this.#state.resultSequence,
) + 1;
completed.resultId = `tool-result-${replacement.resultSequence}`;
this.#state.resultSequence = replacement.resultSequence;
}
replacement.resultSequence = Math.max(
replacement.resultSequence,
this.#state.resultSequence,
);
replacement.operationResults[completed.resultId] = structuredClone(observableValue);
replacement.extensions = replacement.extensions.map((candidate) =>
candidate.key === key
? {
key,
input: record.input,
status: "completed" as const,
resultId: completed.resultId,
execution: structuredClone(completed.value),
}
: candidate,
);
if (this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) {
return {
resultId: completed.resultId,
value: structuredClone(completed.value),
observableValue: structuredClone(observableValue),
};
}
if ((attempt + 1) % RUNTIME_PERSIST_CAS_ATTEMPTS === 0) {
// Yield so lease heartbeats and other runtimes can make progress. The
// bounded outer loop prevents a broken store from pinning invocation
// completion forever; the fulfilled local execution remains available
// for an exact publication retry without re-executing the extension.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
return null;
}
#stopExtensionExecutionLease(record: ExtensionIdempotencyRecord): void {
if (record.leaseHeartbeat !== null) clearInterval(record.leaseHeartbeat);
record.leaseHeartbeat = null;
if (record.cleanupRetry !== null) clearTimeout(record.cleanupRetry);
record.cleanupRetry = null;
}
#restoreState(
snapshot: CapabilitySemanticToolRuntimeSnapshot | null,
): CapabilitySemanticRuntimeState {
if (snapshot === null) {
return {
extensionIdempotency: new Map(),
operationResults: new Map(),
resultSequence: 0,
};
}
const extensionIdempotency = new Map<string, ExtensionIdempotencyRecord>();
for (const extension of snapshot.extensions) {
if (extension.status === "pending") {
extensionIdempotency.set(extension.key, {
input: extension.input,
execution: null,
ownerId: extension.ownerId ?? null,
leaseExpiresAtMs: extension.leaseExpiresAtMs ?? 0,
leaseHeartbeat: null,
cleanupRetry: null,
});
continue;
}
if (extension.status === "indeterminate") {
extensionIdempotency.set(extension.key, {
input: extension.input,
execution: null,
ownerId: null,
leaseExpiresAtMs: 0,
leaseHeartbeat: null,
cleanupRetry: null,
recoveryFailure: extension.reason,
});
continue;
}
const completed = {
resultId: extension.resultId,
value: structuredClone(extension.execution),
};
extensionIdempotency.set(extension.key, {
input: extension.input,
execution: Promise.resolve(structuredClone(completed)),
ownerId: null,
leaseExpiresAtMs: 0,
leaseHeartbeat: null,
cleanupRetry: null,
completed,
});
}
return {
extensionIdempotency,
operationResults: new Map(
Object.entries(snapshot.operationResults).map(([key, value]) => [
key,
structuredClone(value),
]),
),
resultSequence: snapshot.resultSequence,
};
}
#localSnapshot(): CapabilitySemanticToolRuntimeSnapshot {
return {
schema: "paperclip.capability.semantic-tool-runtime.v1",
resultSequence: this.#state.resultSequence,
operationResults: Object.fromEntries(
[...this.#state.operationResults].map(([key, value]) => [
key,
structuredClone(value),
]),
),
extensions: [...this.#state.extensionIdempotency]
.map(([key, record]) =>
record.completed === undefined
? record.recoveryFailure === undefined
? {
key,
input: record.input,
status: "pending" as const,
...(record.ownerId === null ? {} : { ownerId: record.ownerId }),
leaseExpiresAtMs: record.leaseExpiresAtMs,
}
: {
key,
input: record.input,
status: "indeterminate" as const,
reason: record.recoveryFailure,
}
: {
key,
input: record.input,
status: "completed" as const,
resultId: record.completed.resultId,
execution: structuredClone(record.completed.value),
},
),
};
}
#persistSequencedOperationResult(value: CapabilityJsonValue): string {
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const replacement = durable === null
? this.#localSnapshot()
: structuredClone(durable);
const sequence = Math.max(
replacement.resultSequence,
this.#state.resultSequence,
) + 1;
const resultId = `tool-result-${sequence}`;
replacement.resultSequence = sequence;
replacement.operationResults[resultId] = structuredClone(value);
if (!this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) continue;
this.#state.resultSequence = sequence;
this.#state.operationResults.set(resultId, structuredClone(value));
return resultId;
}
throw new Error("durable semantic tool runtime changed during result allocation");
}
#persistState(): void {
const localSnapshot = this.#localSnapshot();
for (let attempt = 0; attempt < RUNTIME_PERSIST_CAS_ATTEMPTS; attempt += 1) {
const durable = this.#adapter.loadSemanticToolRuntime(this.#runId);
const replacement = durable === null
? structuredClone(localSnapshot)
: structuredClone(durable);
replacement.resultSequence = Math.max(
replacement.resultSequence,
localSnapshot.resultSequence,
);
for (const [resultId, value] of Object.entries(localSnapshot.operationResults)) {
const existing = replacement.operationResults[resultId];
if (existing !== undefined && canonicalJson(existing) !== canonicalJson(value)) {
throw new Error("durable semantic tool result id was reused");
}
replacement.operationResults[resultId] = structuredClone(value);
}
// Extension leases and completed receipts use their own CAS path. A
// general invocation must preserve the latest durable copy instead of
// replacing it with a stale restored adapter snapshot.
if (this.#adapter.compareAndSwapSemanticToolRuntime(
this.#runId,
durable,
replacement,
)) {
this.#state.resultSequence = replacement.resultSequence;
return;
}
}
throw new Error("durable semantic tool runtime changed during persistence");
}
async #execute(
descriptor: CapabilitySemanticToolDescriptor,
invocation: CapabilityToolInvocation,
): Promise<{ value: CapabilityJsonValue; commandResult: CapabilityToolSuccess["commandResult"]; entityRefs: string[] }> {
const input = asObject(invocation.input);
switch (descriptor.mockCommandMapping.kind) {
case "context_read":
return { value: toJsonValue(this.#adapter.context(this.#runId)), commandResult: null, entityRefs: [] };
case "snapshot_read":
return {
value: this.#snapshotProjection(descriptor.mockCommandMapping.projection, input),
commandResult: null,
entityRefs: [],
};
case "operation_result": {
const id = requireString(input.operationResultId);
const result = this.#state.operationResults.get(id);
if (result === undefined) throw new Error("operation result is not available");
return { value: structuredClone(result), commandResult: null, entityRefs: [] };
}
case "semantic_command": {
const command = commandForOperation(descriptor.operationId, input);
const envelope: CapabilityCommandEnvelope = {
runId: this.#runId,
idempotencyKey: invocation.idempotencyKey!,
command,
};
const commandResult = await this.#adapter.applyCommand(envelope);
return {
value: toJsonValue(commandResult),
commandResult,
entityRefs: commandResult.entityRefs,
};
}
case "mock_extension":
return this.#executeExtension(descriptor, input);
}
}
async #executeExtension(
descriptor: CapabilitySemanticToolDescriptor,
input: Record<string, CapabilityJsonValue>,
): Promise<ExtensionExecution> {
const extension = descriptor.mockCommandMapping.kind === "mock_extension"
? descriptor.mockCommandMapping.extension
: "";
if (extension === "secrets.value") {
// Secret reads are explicitly non-idempotent and never enter the durable
// extension lease path, which also keeps secret values out of receipts.
const name = requireString(input.name);
const value = await this.#resolveSecretValue?.(name);
if (value === null || value === undefined) throw new Error("secret is not available");
return { value: { name, value }, commandResult: null, entityRefs: [] };
}
return this.#prepareBuiltInExtensionExecution(descriptor, input);
}
#prepareBuiltInExtensionExecution(
descriptor: CapabilitySemanticToolDescriptor,
input: Record<string, CapabilityJsonValue>,
): ExtensionExecution {
switch (descriptor.mockCommandMapping.kind === "mock_extension"
? descriptor.mockCommandMapping.extension
: "") {
case "discovery.projects":
case "discovery.goals":
case "cases.list":
case "routines.list":
case "company_skills.list":
case "secrets.metadata":
return { value: [], commandResult: null, entityRefs: [] };
case "portability.export": {
const snapshot = this.#adapter.snapshot();
return {
value: {
schema: "paperclip.capability.mock-export.v1",
company: { id: snapshot.company.id, name: snapshot.company.name },
taskCount: snapshot.tasks.length,
actorCount: snapshot.actors.length,
},
commandResult: null,
entityRefs: [],
};
}
case "company_skills.sync":
return {
value: { synced: true },
commandResult: null,
entityRefs: [],
};
case "routines.manage":
return {
value: { managed: true },
commandResult: null,
entityRefs: [],
};
case "cases.upsert":
return {
value: {
key: requireString(input.key),
body: requireString(input.body),
upserted: true,
},
commandResult: null,
entityRefs: [],
};
case "company.admin":
return {
value: {
action: requireString(input.action),
applied: true,
},
commandResult: null,
entityRefs: [],
};
case "test.generic_api":
return {
value: {
status: 200,
body: null,
warning: "TEST-ONLY mock request; no control plane or network was contacted.",
},
commandResult: null,
entityRefs: [],
};
case "secrets.value":
throw new Error("non-idempotent secret reads cannot enter durable extension execution");
default:
throw new Error("mock extension is not implemented");
}
}
#snapshotProjection(
projection: string,
input: Record<string, CapabilityJsonValue>,
): CapabilityJsonValue {
const snapshot = this.#adapter.snapshot();
const context = this.#adapter.context(this.#runId);
switch (projection) {
case "active_task_history":
return toJsonValue(snapshot.comments.filter((comment) => comment.taskId === context.activeTask.id));
case "active_task_documents":
return toJsonValue(snapshot.documents
.filter((document) => document.taskId === context.activeTask.id)
.map(({ id, key, title, format, latestRevisionId, revisions }) => ({
id, key, title, format, latestRevisionId, revisionCount: revisions.length,
})));
case "active_task_document": {
const key = requireString(input.key);
const document = snapshot.documents.find(
(candidate) => candidate.taskId === context.activeTask.id && candidate.key === key,
);
if (document === undefined) throw new Error("document is not available");
return toJsonValue(document);
}
case "active_task_document_revisions": {
const key = requireString(input.key);
const document = snapshot.documents.find(
(candidate) => candidate.taskId === context.activeTask.id && candidate.key === key,
);
if (document === undefined) throw new Error("document is not available");
return toJsonValue(document.revisions);
}
case "company_tasks": {
const query = typeof input.query === "string" ? input.query.toLowerCase() : "";
const status = typeof input.status === "string" ? input.status : null;
return toJsonValue(snapshot.tasks.filter(
(task) =>
(query === "" || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) &&
(status === null || task.status === status),
));
}
case "company_actors":
return toJsonValue(snapshot.actors.map(({ id, companyId, name, role, status }) => ({ id, companyId, name, role, status })));
case "company_approvals":
return toJsonValue(snapshot.approvals);
case "active_task_workspace":
return toJsonValue(snapshot.workspaceServices.filter((service) => service.taskId === context.activeTask.id));
default:
throw new Error("snapshot projection is not implemented");
}
}
#context(): CapabilityToolAuthorizationContext {
const runContext = this.#adapter.context(this.#runId);
return {
runId: this.#runId,
actor: {
id: runContext.actor.id,
role: runContext.actor.role,
capabilityGrants: [...runContext.actor.capabilityGrants],
},
task: {
id: runContext.activeTask.id,
assigneeActorId: runContext.activeTask.assigneeActorId,
workMode: runContext.activeTask.workMode,
},
scenarioGrants: [...this.#scenarioGrants],
policy: this.#policy,
};
}
#isSelfApproval(input: CapabilityJsonValue): boolean {
const approvalId = asObject(input).approvalId;
if (typeof approvalId !== "string") return false;
const actorId = this.#adapter.context(this.#runId).actor.id;
const approval = this.#adapter.snapshot().approvals.find((candidate) => candidate.id === approvalId);
return approval?.requestedByActorId === actorId;
}
#denial(
operationId: string,
authorization: CapabilityAuthorizationRecord,
code: CapabilityPolicyDenial["error"]["code"],
): CapabilityPolicyDenial {
return deepFreeze({
schema: "paperclip.capability.tool-result.v1",
ok: false,
error: {
code,
message: code === "operation_absent"
? "The requested semantic operation is not available."
: "The requested semantic operation was not executed.",
operationId,
reason: authorization.reason,
},
authorization,
});
}
}
function commandForOperation(
operationId: string,
input: Record<string, CapabilityJsonValue>,
): CapabilitySemanticCommand {
switch (operationId) {
case "report_progress":
case "answer_status_question":
return { kind: "report_progress", body: requireString(input.body) };
case "finish_task":
return { kind: "finish_task", summary: requireString(input.summary) };
case "block_task":
return {
kind: "block_task",
reason: requireString(input.reason),
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
};
case "request_review":
return { kind: "request_review", summary: requireString(input.summary) };
case "create_skill":
return { kind: "create_skill", name: requireString(input.name),
slug: typeof input.slug === "string" ? input.slug : undefined,
description: requireString(input.description), markdown: requireString(input.markdown) };
case "update_skill":
return { kind: "update_skill", skillId: requireString(input.skillId),
expectedVersionId: requireString(input.expectedVersionId), markdown: requireString(input.markdown) };
case "write_document":
return {
kind: "write_document",
key: requireString(input.key),
title: requireString(input.title),
body: requireString(input.body, true),
baseRevisionId: input.baseRevisionId === null ? null : requireString(input.baseRevisionId),
changeSummary: typeof input.changeSummary === "string" ? input.changeSummary : undefined,
};
case "request_human_input":
return {
kind: "request_human_input",
interactionKind: requireString(input.interactionKind) as never,
title: requireString(input.title),
prompt: requireString(input.prompt),
payload: input.payload,
targetRevisionId: input.targetRevisionId === null || input.targetRevisionId === undefined
? input.targetRevisionId
: requireString(input.targetRevisionId),
continuationPolicy: requireString(input.continuationPolicy) as never,
};
case "register_deliverable":
return {
kind: "register_deliverable",
filename: requireString(input.filename),
contentType: requireString(input.contentType),
byteSize: input.byteSize as number,
sha256: requireString(input.sha256),
contentRef: requireString(input.contentRef),
title: requireString(input.title),
};
case "create_task":
return {
kind: "create_task",
status: input.status as "backlog" | "todo" | undefined,
title: requireString(input.title),
description: typeof input.description === "string" ? input.description : undefined,
assigneeActorId: typeof input.assigneeActorId === "string" ? input.assigneeActorId : undefined,
priority: typeof input.priority === "string" ? input.priority as never : undefined,
blockedByTaskIds: optionalStringArray(input.blockedByTaskIds),
};
case "reassign_task":
return { kind: "reassign_task", targetTaskId: requireString(input.taskId), assigneeActorId: requireString(input.assigneeActorId), expectedAssigneeActorId: input.expectedAssigneeActorId === null ? null : requireString(input.expectedAssigneeActorId), expectedStatusVersion: Number(input.expectedStatusVersion), reason: requireString(input.reason) };
case "set_dependencies":
return { kind: "set_dependencies", blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) ?? [] };
case "request_approval":
return { kind: "request_approval", approvalType: requireString(input.approvalType), payload: input.payload ?? {} };
case "decide_approval":
return {
kind: "decide_approval",
approvalId: requireString(input.approvalId),
decision: requireString(input.decision) as never,
note: requireString(input.note),
};
case "comment_on_approval":
return { kind: "comment_on_approval", approvalId: requireString(input.approvalId), body: requireString(input.body) };
case "control_workspace_service":
return {
kind: "control_workspace_service",
serviceId: requireString(input.serviceId),
action: requireString(input.action) as never,
url: typeof input.url === "string" ? input.url : undefined,
};
default:
throw new Error("semantic command mapping is not implemented");
}
}
export function validateJsonSchema(schema: CapabilityJsonSchema, value: CapabilityJsonValue, path = "$" ): string[] {
if (schema.oneOf !== undefined) {
return schema.oneOf.some((candidate) => validateJsonSchema(candidate, value, path).length === 0)
? []
: [`${path} does not match any allowed shape`];
}
if (schema.type !== undefined && !matchesType(schema.type, value)) return [`${path} must be ${schema.type}`];
if (schema.enum !== undefined && !schema.enum.some((candidate) => JSON.stringify(candidate) === JSON.stringify(value))) {
return [`${path} must be an allowed value`];
}
if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
return [`${path} is too short`];
}
if (typeof value === "number" && schema.minimum !== undefined && value < schema.minimum) {
return [`${path} is below the minimum`];
}
if (Array.isArray(value) && schema.items !== undefined) {
return value.flatMap((item, index) => validateJsonSchema(schema.items!, item, `${path}[${index}]`));
}
if (schema.type === "object" && typeof value === "object" && value !== null && !Array.isArray(value)) {
const object = value as Record<string, CapabilityJsonValue>;
const issues = (schema.required ?? [])
.filter((key) => !(key in object))
.map((key) => `${path}.${key} is required`);
for (const [key, child] of Object.entries(object)) {
const childSchema = schema.properties?.[key];
if (childSchema !== undefined) issues.push(...validateJsonSchema(childSchema, child, `${path}.${key}`));
else if (schema.additionalProperties === false) issues.push(`${path}.${key} is not allowed`);
else if (typeof schema.additionalProperties === "object") {
issues.push(...validateJsonSchema(schema.additionalProperties, child, `${path}.${key}`));
}
}
return issues;
}
return [];
}
function matchesType(type: NonNullable<CapabilityJsonSchema["type"]>, value: CapabilityJsonValue): boolean {
if (Array.isArray(type)) return type.some((candidate) => matchesType(candidate, value));
switch (type) {
case "null": return value === null;
case "array": return Array.isArray(value);
case "object": return typeof value === "object" && value !== null && !Array.isArray(value);
case "integer": return typeof value === "number" && Number.isSafeInteger(value);
default: return typeof value === type;
}
}
function redactForBoundary(
descriptor: CapabilitySemanticToolDescriptor,
value: CapabilityJsonValue,
boundary: CapabilitySemanticToolDescriptor["redaction"][number]["appliesTo"][number],
): CapabilityJsonValue {
let redacted = structuredClone(value);
for (const rule of descriptor.redaction) {
if (!rule.appliesTo.includes(boundary)) continue;
redacted = replaceJsonPath(redacted, rule.path, rule.replacement);
}
return redacted;
}
function replaceJsonPath(
value: CapabilityJsonValue,
path: string,
replacement: CapabilityJsonValue,
): CapabilityJsonValue {
if (path === "$") return replacement;
if (!path.startsWith("$.")) return value;
const segments = path.slice(2).split(".");
if (segments.some((segment) => segment.length === 0)) return value;
const root = structuredClone(value);
let cursor: CapabilityJsonValue = root;
for (const segment of segments.slice(0, -1)) {
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return root;
const next: CapabilityJsonValue | undefined = cursor[segment];
if (next === undefined) return root;
cursor = next;
}
if (typeof cursor !== "object" || cursor === null || Array.isArray(cursor)) return root;
const leaf = segments.at(-1)!;
if (!(leaf in cursor)) return root;
cursor[leaf] = replacement;
return root;
}
function optionalStringArray(value: CapabilityJsonValue | undefined): string[] | undefined {
if (value === undefined) return undefined;
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw new Error("expected string array");
return value as string[];
}
function requireString(value: CapabilityJsonValue | undefined, allowEmpty = false): string {
if (typeof value !== "string" || (!allowEmpty && value.length === 0)) throw new Error("expected string");
return value;
}
function asObject(value: CapabilityJsonValue): Record<string, CapabilityJsonValue> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error("expected object input");
return value;
}
function toJsonValue(value: unknown): CapabilityJsonValue {
return structuredClone(value) as CapabilityJsonValue;
}
function canonicalJson(value: CapabilityJsonValue): string {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
if (typeof value === "object" && value !== null) {
return `{${Object.keys(value).sort().map((key) =>
`${JSON.stringify(key)}:${canonicalJson(value[key]!)}`
).join(",")}}`;
}
return JSON.stringify(value);
}
function deepFreeze<T>(value: T): T {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
return value;
}
class CapabilityModelToolDeliveryImpl implements CapabilityModelToolDelivery {
readonly observableResult: CapabilityToolInvocationResult;
readonly #modelResult: CapabilityModelToolInvocationResult;
constructor(
observableResult: CapabilityToolInvocationResult,
modelResult: CapabilityModelToolInvocationResult,
) {
this.observableResult = observableResult;
this.#modelResult = modelResult;
Object.freeze(this);
}
readModelResult(): CapabilityModelToolInvocationResult {
return deepFreeze(structuredClone(this.#modelResult));
}
toJSON(): CapabilityToolInvocationResult {
return this.observableResult;
}
}