@segmentstream-cloud/ontology 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -311,6 +311,45 @@ pair, or when the agent refuses, the action is rejected with the reason. Both ac
311
311
  `agent_chat:send_message`), which no role holds until the workspace grants it, and reading
312
312
  chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_chat`.
313
313
 
314
+ ### Agent attachments
315
+
316
+ A file a member sends the agent is a temporary attachment: platform metadata in the runtime
317
+ table `_agent_attachments`, never an ontology record, with its bytes stored once as a revision in
318
+ the ontology's file storage, beside uploads. It belongs to the member, the chat it arrived in and
319
+ the agent session that first reads it, holds its SHA-256, size, type and name, and expires 24
320
+ hours after receipt (`received` → `ready` → `promoted`, `discarded` or `expired`). Files are up
321
+ to 20 MB, what Telegram lets a bot download; a member holds at most 50 live attachments. The
322
+ table exists on deployments with file storage; the migration adds it beside the file tables, also
323
+ to a database an earlier SDK migrated.
324
+
325
+ Only the agent calls these routes, with its runtime token, `X-SegmentStream-Act-As`, the chat in
326
+ `X-SegmentStream-Agent-Conversation` (`telegram:<chat id>`) and, inside a turn, the session in
327
+ `X-SegmentStream-Agent-Session`. Another member's attachment, another chat's or another
328
+ session's answers 404; an expired or discarded one answers 409; a deactivated member's 403.
329
+
330
+ | Route | Does |
331
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
332
+ | `POST /api/ontology/agent/attachments` | `{ source, source_ref, filename, content_type, size, sha256 }` → `{ attachment, upload? }`, a PUT grant |
333
+ | `POST /api/ontology/agent/attachments/{id}/complete` | Verifies the stored bytes' size and SHA-256 → `{ attachment }` in `ready` |
334
+ | `GET /api/ontology/agent/attachments` | `{ attachments }`: the caller's readable attachments in this chat and session |
335
+ | `GET /api/ontology/agent/attachments/{id}` | `{ attachment, download }`: a one-minute GET grant |
336
+ | `POST /api/ontology/agent/attachments/{id}/promote` | `{ entity, action, input?, field?, record_id? }` → `{ attachment, deduplicated, result }` |
337
+ | `POST /api/ontology/agent/attachments/{id}/discard` | Revokes it at once → `{ attachment }` |
338
+
339
+ Promotion keeps a file by running an action that takes an upload, such as an authored
340
+ `document.upload` with `uploads.for(Document, "content")`, as the member and with their
341
+ permissions. The service mints a completed upload ticket on the attachment's existing revision,
342
+ fills the action's upload field (and `sha256`, when the input has one and the caller left it
343
+ out), and runs the action with an idempotency key derived from the attachment, action and
344
+ input, and the ticket's create intent; the action consumes the ticket as it would a browser
345
+ upload, without copying the bytes. The target property's `accept` and `maxBytes` apply. A file
346
+ whose SHA-256 an earlier attachment already promoted through the same action answers that
347
+ promotion with `deduplicated: true` instead of a second record. Storage maintenance expires
348
+ attachments past their deadline and removes the files no record adopted.
349
+
350
+ An MCP action tool whose input takes an upload also accepts `_options: { idempotency_key,
351
+ create_intent }`, the options the REST route takes beside `input`, so an MCP client can run it.
352
+
314
353
  ## The `segmentstream-ontology` binary
315
354
 
316
355
  Run the binary in the `ontology/` folder. The entry defaults to `./index.ts`. The binary
@@ -6,6 +6,7 @@ import { isNoopMigration } from "./model.js";
6
6
  import { planMigration } from "./plan.js";
7
7
  import { REPLICATION_DDL, REPLICATION_SCHEMA_VERSION, replicationEntityDdl, } from "../replication/schema.js";
8
8
  import { createReplicationBaseline } from "../replication/baseline.js";
9
+ import { ATTACHMENTS_TABLE } from "../storage/tables.js";
9
10
  import { resolveReplicationSelection } from "../replication/model.js";
10
11
  import { AGENT_TABLES, API_KEYS_TABLE, CATALOG_TABLE, OAUTH_TABLES, USER_ROLES_TABLE, quoteIdentifier, quoteLiteral, } from "./storage.js";
11
12
  const CATALOG = quoteIdentifier(CATALOG_TABLE);
@@ -172,6 +173,7 @@ export const RUNTIME_TABLES = [
172
173
  API_KEYS_TABLE,
173
174
  ...OAUTH_TABLES,
174
175
  ...AGENT_TABLES,
176
+ ATTACHMENTS_TABLE,
175
177
  ];
176
178
  /**
177
179
  * The runtime tables the database has, for `planMigration`'s `existingTables`: a database that an
@@ -1,5 +1,5 @@
1
1
  import { isValueMatched, } from "../manifest.js";
2
- import { BLOB_TABLES_DDL } from "../storage/tables.js";
2
+ import { ATTACHMENTS_TABLE, ATTACHMENT_TABLES_DDL, BLOB_TABLES_DDL } from "../storage/tables.js";
3
3
  import { SNAKE_CASE_NAME, isSystemProperty, toSnakeCase } from "../names.js";
4
4
  import { AGENT_TABLES, AGENT_TABLES_DDL, CATALOG_TABLE_DDL, MAX_IDENTIFIER_LENGTH, OAUTH_TABLES, OAUTH_TABLES_DDL, USER_TABLE, addConstraintStatement, columnDefinition, constraintDefinition, createEnumStatement, createIndexStatement, createTableStatement, defaultExpression, entityStorage, enumTypeName, fieldColumnName, joinTable, propertyCheck, blobForeignKey, propertyColumn, propertyEnum, quoteIdentifier, quoteLiteral, relationshipColumn, RUNTIME_TABLES_DDL, relationshipForeignKey, SYSTEM_COLUMNS, tableName, } from "./storage.js";
5
5
  const GLOBAL_KINDS = new Set(["table", "type", "index"]);
@@ -94,13 +94,23 @@ export function planMigration(input) {
94
94
  }));
95
95
  }
96
96
  }
97
- if (storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob")) &&
97
+ const storesFiles = storedTargets.some((entity) => entity.properties.some((property) => property.type === "blob"));
98
+ if (storesFiles &&
98
99
  !current?.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))) {
99
100
  draft.runtimeTables.push(step({
100
101
  kind: "createCatalog",
101
102
  classification: "additive",
102
- description: "Create the ontology's file revision and upload tables.",
103
- sql: BLOB_TABLES_DDL,
103
+ description: "Create the ontology's file revision, upload and agent attachment tables.",
104
+ sql: [...BLOB_TABLES_DDL, ...ATTACHMENT_TABLES_DDL],
105
+ }));
106
+ }
107
+ else if (storesFiles && input.existingTables?.has(ATTACHMENTS_TABLE) === false) {
108
+ // File storage an earlier SDK created lacks the agent's attachments, added since.
109
+ draft.runtimeTables.push(step({
110
+ kind: "createCatalog",
111
+ classification: "additive",
112
+ description: "Create the agent's temporary attachment table beside the file tables.",
113
+ sql: ATTACHMENT_TABLES_DDL,
104
114
  }));
105
115
  }
106
116
  planDrops({ pairs, removedEntities, addedEntities, allowDrops, draft });
@@ -3,6 +3,7 @@ import { Effect, Layer, Redacted } from "effect";
3
3
  import { SqlClient } from "effect/unstable/sql";
4
4
  import { neonBlobProvider } from "../storage/neon.js";
5
5
  import { maintainBlobStorage } from "../storage/maintenance.js";
6
+ import { attachmentRuntime } from "../storage/attachments.js";
6
7
  import { blobRuntime } from "../storage/sessions.js";
7
8
  import { readCatalog } from "../migrate/apply.js";
8
9
  import { NotInvited, OntologyBackend, } from "../service/runtime-contract.js";
@@ -33,8 +34,10 @@ export function backendLayer(ontology, blobStorage) {
33
34
  ? yield* Effect.acquireRelease(Effect.sync(() => neonBlobProvider(binding)), (provider) => Effect.sync(() => provider.close?.()))
34
35
  : undefined;
35
36
  const blobs = provider ? blobRuntime(ontology, sql, provider) : undefined;
37
+ const attachments = provider ? attachmentRuntime(ontology, sql, provider) : undefined;
36
38
  return OntologyBackend.of({
37
39
  ...(blobs ? { blobs } : {}),
40
+ ...(attachments ? { attachments } : {}),
38
41
  ...(provider ? { maintainBlobs: maintainBlobStorage(sql, provider) } : {}),
39
42
  runAction: (input) => withSql(runAction(ontology, {
40
43
  entity: input.entity,
@@ -0,0 +1,37 @@
1
+ import { Layer } from "effect";
2
+ import { HttpRouter } from "effect/unstable/http";
3
+ import { type TokenVerifier } from "../authentication.js";
4
+ import type { AfterCommitHook } from "../config.js";
5
+ import type { ConnectionTransportFactory } from "../connections.js";
6
+ import { RequestId } from "../errors.js";
7
+ import type { ApiPlan } from "../plan.js";
8
+ import type { ReplicationKick } from "../replication.js";
9
+ import { OntologyBackend } from "../runtime-contract.js";
10
+ import { type RuntimeTokenVerifier } from "../runtime-identity.js";
11
+ import type { AgentChannelFactory } from "./channel.js";
12
+ /**
13
+ * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
+ * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
15
+ * while they are active and paired. The chat and the agent session come from the agent's runtime in
16
+ * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
17
+ * tool arguments; every operation is confined to that member, chat and session.
18
+ */
19
+ export declare const ATTACHMENTS_PATH = "/api/ontology/agent/attachments";
20
+ /** The chat an attachment belongs to, such as `telegram:12345`. */
21
+ export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
+ /** The agent session the request comes from. */
23
+ export declare const SESSION_HEADER = "x-segmentstream-agent-session";
24
+ export interface AttachmentRoutesOptions {
25
+ readonly plan: ApiPlan;
26
+ readonly hooks: ReadonlyMap<string, AfterCommitHook>;
27
+ readonly connections: ConnectionTransportFactory;
28
+ readonly replicate: ReplicationKick;
29
+ readonly agentChannel?: AgentChannelFactory | undefined;
30
+ readonly verifyToken: TokenVerifier;
31
+ readonly verifyRuntime: RuntimeTokenVerifier | undefined;
32
+ }
33
+ /**
34
+ * The attachment routes. Without file storage or the agent tables, or while runtime authentication
35
+ * is disabled, it serves nothing.
36
+ */
37
+ export declare function attachmentsLayer(options: AttachmentRoutesOptions): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
@@ -0,0 +1,225 @@
1
+ import { Cause, Effect, Layer, Redacted, Result, Schema } from "effect";
2
+ import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
+ import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
+ import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
5
+ import { schemaIssues } from "../../runtime/validation.js";
6
+ import { CurrentPrincipal } from "../authentication.js";
7
+ import { RequestId, errorBodyForWire, failureResponse, forbidden, toServerResponse, unauthorized, unavailable, } from "../errors.js";
8
+ import { identityToken, makeOperations } from "../operations.js";
9
+ import { OntologyBackend } from "../runtime-contract.js";
10
+ import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
11
+ import { AGENT_PATH } from "./server.js";
12
+ /**
13
+ * The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
14
+ * deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
15
+ * while they are active and paired. The chat and the agent session come from the agent's runtime in
16
+ * `X-SegmentStream-Agent-Conversation` and `X-SegmentStream-Agent-Session`, never from the model's
17
+ * tool arguments; every operation is confined to that member, chat and session.
18
+ */
19
+ export const ATTACHMENTS_PATH = `${AGENT_PATH}/attachments`;
20
+ /** The chat an attachment belongs to, such as `telegram:12345`. */
21
+ export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
22
+ /** The agent session the request comes from. */
23
+ export const SESSION_HEADER = "x-segmentstream-agent-session";
24
+ const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
25
+ const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
26
+ const NO_STORE = { "cache-control": "no-store" };
27
+ const Received = Schema.Struct({
28
+ source: Schema.Literals(ATTACHMENT_SOURCES),
29
+ source_ref: Schema.String,
30
+ filename: Schema.String,
31
+ content_type: Schema.String,
32
+ size: Schema.Int,
33
+ sha256: Schema.String,
34
+ });
35
+ const Promotion = Schema.Struct({
36
+ entity: Schema.String,
37
+ action: Schema.String,
38
+ input: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)),
39
+ field: Schema.optionalKey(Schema.String),
40
+ record_id: Schema.optionalKey(Schema.String),
41
+ });
42
+ function json(body, status = 200) {
43
+ return HttpServerResponse.jsonUnsafe(body, { status, headers: NO_STORE });
44
+ }
45
+ function attachmentForWire(attachment) {
46
+ return {
47
+ id: attachment.id,
48
+ filename: attachment.filename,
49
+ content_type: attachment.contentType,
50
+ size: attachment.size,
51
+ sha256: attachment.sha256,
52
+ state: attachment.state,
53
+ created_at: attachment.createdAt,
54
+ expires_at: attachment.expiresAt,
55
+ promotion: attachment.promotion,
56
+ };
57
+ }
58
+ function grantForWire(grant) {
59
+ return {
60
+ url: grant.url,
61
+ method: grant.method,
62
+ headers: grant.headers,
63
+ expires_at: grant.expiresAt,
64
+ };
65
+ }
66
+ function decodeBody(schema, request) {
67
+ return Effect.gen(function* () {
68
+ const text = yield* Effect.orDie(request.text);
69
+ let body;
70
+ try {
71
+ body = JSON.parse(text);
72
+ }
73
+ catch {
74
+ body = undefined;
75
+ }
76
+ const decoded = yield* Effect.result(Schema.decodeUnknownEffect(schema)(body, { errors: "all", onExcessProperty: "error" }));
77
+ return Result.isFailure(decoded)
78
+ ? { ok: false, issues: schemaIssues(decoded.failure) }
79
+ : { ok: true, value: decoded.success };
80
+ });
81
+ }
82
+ function invalidBody(requestId, issues) {
83
+ return HttpServerResponse.jsonUnsafe(errorBodyForWire({
84
+ error: "invalid_request",
85
+ detail: "The request body is invalid.",
86
+ requestId,
87
+ issues: issues,
88
+ }), { status: 400 });
89
+ }
90
+ /** Answers a failure the way every other route does: expected ones by status, the rest as 500. */
91
+ function answered(effect) {
92
+ return Effect.gen(function* () {
93
+ const requestId = yield* RequestId;
94
+ return yield* effect.pipe(Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
95
+ ? Effect.failCause(cause)
96
+ : failureResponse(cause, requestId)));
97
+ });
98
+ }
99
+ /**
100
+ * The attachment routes. Without file storage or the agent tables, or while runtime authentication
101
+ * is disabled, it serves nothing.
102
+ */
103
+ export function attachmentsLayer(options) {
104
+ return Layer.effectDiscard(Effect.gen(function* () {
105
+ const router = yield* HttpRouter.HttpRouter;
106
+ const backend = yield* OntologyBackend;
107
+ const attachments = backend.attachments;
108
+ const agents = backend.agents;
109
+ const verifyRuntime = options.verifyRuntime;
110
+ if (attachments === undefined || agents === undefined || verifyRuntime === undefined) {
111
+ return;
112
+ }
113
+ const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
114
+ const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
115
+ /** Authenticates the runtime, then the member it acts for, the chat and the session. */
116
+ const asAgent = (answer) => (request) => Effect.gen(function* () {
117
+ const requestId = yield* RequestId;
118
+ const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
119
+ const token = Redacted.value(bearer);
120
+ const verified = token === "" ? undefined : yield* Effect.promise(() => verifyRuntime(token));
121
+ switch (verified?.status) {
122
+ case "valid":
123
+ break;
124
+ case "invalid":
125
+ yield* logRuntimeRejection(verified.reason);
126
+ return toServerResponse(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
127
+ case "unavailable":
128
+ return toServerResponse(unavailable(requestId));
129
+ default:
130
+ return toServerResponse(forbidden(requestId, "Only the app's agent calls this route."));
131
+ }
132
+ const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
133
+ const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
134
+ const session = request.headers[SESSION_HEADER]?.trim() ?? "";
135
+ const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
136
+ if (member === undefined) {
137
+ return toServerResponse(forbidden(requestId, "The member is inactive or has not paired with the agent."));
138
+ }
139
+ if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
140
+ return toServerResponse(forbidden(requestId, "The agent names the chat and session of every request."));
141
+ }
142
+ return yield* answer(request, {
143
+ principal: member,
144
+ conversation,
145
+ ...(session === "" ? {} : { sessionId: session }),
146
+ }, attachments);
147
+ });
148
+ const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
149
+ yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
150
+ const requestId = yield* RequestId;
151
+ const body = yield* decodeBody(Received, request);
152
+ if (!body.ok) {
153
+ return invalidBody(requestId, body.issues);
154
+ }
155
+ const received = yield* runtime.receive({
156
+ source: body.value.source,
157
+ sourceRef: body.value.source_ref,
158
+ filename: body.value.filename,
159
+ contentType: body.value.content_type,
160
+ size: body.value.size,
161
+ sha256: body.value.sha256,
162
+ }, caller);
163
+ return json({
164
+ attachment: attachmentForWire(received.attachment),
165
+ ...(received.upload ? { upload: grantForWire(received.upload) } : {}),
166
+ }, 201);
167
+ }))));
168
+ yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/complete`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
169
+ const attachment = yield* runtime.complete(yield* idOf, caller);
170
+ return json({ attachment: attachmentForWire(attachment) });
171
+ }))));
172
+ yield* router.add("GET", ATTACHMENTS_PATH, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
173
+ const listed = yield* runtime.list(caller);
174
+ return json({ attachments: listed.map(attachmentForWire) });
175
+ }))));
176
+ yield* router.add("GET", `${ATTACHMENTS_PATH}/:id`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
177
+ const { attachment, download } = yield* runtime.read(yield* idOf, caller);
178
+ return json({
179
+ attachment: attachmentForWire(attachment),
180
+ download: grantForWire(download),
181
+ });
182
+ }))));
183
+ yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/discard`, asAgent((_, caller, runtime) => answered(Effect.gen(function* () {
184
+ const attachment = yield* runtime.discard(yield* idOf, caller);
185
+ return json({ attachment: attachmentForWire(attachment) });
186
+ }))));
187
+ yield* router.add("POST", `${ATTACHMENTS_PATH}/:id/promote`, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
188
+ const requestId = yield* RequestId;
189
+ const body = yield* decodeBody(Promotion, request);
190
+ if (!body.ok) {
191
+ return invalidBody(requestId, body.issues);
192
+ }
193
+ const promotion = body.value;
194
+ const promoted = yield* runtime.promote(yield* idOf, {
195
+ entity: promotion.entity,
196
+ action: promotion.action,
197
+ input: promotion.input ?? {},
198
+ ...(promotion.field === undefined ? {} : { field: promotion.field }),
199
+ ...(promotion.record_id === undefined ? {} : { recordId: promotion.record_id }),
200
+ }, caller, (call) => Effect.gen(function* () {
201
+ const planned = options.plan.entities.find((entity) => entity.name === call.entity);
202
+ const action = planned?.actions.find((item) => item.name === call.action);
203
+ if (planned === undefined || action === undefined) {
204
+ return yield* Effect.die(new Error("The promotion's action was not planned."));
205
+ }
206
+ // The action runs as the member, with the after-commit hooks and connections
207
+ // of any other call.
208
+ const answer = yield* operations
209
+ .runAction(planned, action, {
210
+ input: call.input,
211
+ validateOnly: false,
212
+ options: call.options,
213
+ identityToken: identityToken(request),
214
+ })
215
+ .pipe(Effect.provideService(CurrentPrincipal, caller.principal), Effect.provideService(RequestId, requestId));
216
+ return { result: answer.result, edits: answer.edits };
217
+ }));
218
+ return json({
219
+ attachment: attachmentForWire(promoted.attachment),
220
+ deduplicated: promoted.deduplicated,
221
+ result: promoted.result,
222
+ });
223
+ }))));
224
+ }));
225
+ }
@@ -1,4 +1,4 @@
1
- import { Cause, Data, Effect, Layer, Option, Schema } from "effect";
1
+ import { Cause, Data, Effect, Layer, Option, Schema, SchemaTransformation } from "effect";
2
2
  import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
3
  import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
4
4
  import { encodeManifest } from "../../manifest.js";
@@ -14,7 +14,8 @@ import { identityToken, makeOperations, toQuery } from "../operations.js";
14
14
  import { OntologyBackend } from "../runtime-contract.js";
15
15
  import { runtimeCredential } from "../runtime-identity.js";
16
16
  import { ActionResponse, HistoryPage, HistoryParams, IdParam, Me, } from "../schemas.js";
17
- import { mcpTools, visibleTools } from "./tools.js";
17
+ import { uploadFields } from "../../storage/attachments.js";
18
+ import { ACTION_OPTIONS_ARGUMENT, mcpTools, visibleTools } from "./tools.js";
18
19
  /** The URL path of the ontology's MCP server, outside `/api/ontology`: one per app. */
19
20
  export const MCP_PATH = "/mcp";
20
21
  /** The MCP revisions the server speaks, newest first. */
@@ -66,6 +67,23 @@ function decodeArguments(schema, args) {
66
67
  onExcessProperty: "error",
67
68
  }).pipe(Effect.mapError((error) => new InvalidArguments({ issues: schemaIssues(error.issue) })));
68
69
  }
70
+ /** The `_options` argument of an action tool that consumes an upload. */
71
+ const McpActionOptions = Schema.Struct({
72
+ idempotency_key: Schema.optionalKey(Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128))),
73
+ create_intent: Schema.optionalKey(Schema.String.check(Schema.isUUID())),
74
+ }).pipe(Schema.decodeTo(Schema.Struct({
75
+ idempotencyKey: Schema.optionalKey(Schema.String),
76
+ createIntent: Schema.optionalKey(Schema.String),
77
+ }), SchemaTransformation.transform({
78
+ decode: (value) => ({
79
+ ...(value.idempotency_key === undefined ? {} : { idempotencyKey: value.idempotency_key }),
80
+ ...(value.create_intent === undefined ? {} : { createIntent: value.create_intent }),
81
+ }),
82
+ encode: (value) => ({
83
+ ...(value.idempotencyKey === undefined ? {} : { idempotency_key: value.idempotencyKey }),
84
+ ...(value.createIntent === undefined ? {} : { create_intent: value.createIntent }),
85
+ }),
86
+ })));
69
87
  /** What the service sends: a value encoded with the schema of the matching HTTP route. */
70
88
  function encoded(schema, value) {
71
89
  return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
@@ -178,9 +196,14 @@ function runTool({ built, operations, aggregate }, tool, args, request) {
178
196
  return structured(yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params)));
179
197
  }
180
198
  case "action": {
199
+ const { [ACTION_OPTIONS_ARGUMENT]: rawOptions, ...input } = args;
200
+ const options = rawOptions === undefined || uploadFields(target.action.action.input).length === 0
201
+ ? undefined
202
+ : yield* decodeArguments(McpActionOptions, rawOptions);
181
203
  const answer = yield* operations.runAction(target.planned, target.action, {
182
- input: args,
204
+ input: options === undefined ? args : input,
183
205
  validateOnly: false,
206
+ ...(options === undefined ? {} : { options }),
184
207
  identityToken: identityToken(request),
185
208
  });
186
209
  return structured(yield* encoded(ActionResponse, answer));
@@ -43,6 +43,12 @@ export interface McpTool {
43
43
  readonly inputSchemaFor?: (caller: PermissionHolder) => JsonSchemaObject | undefined;
44
44
  readonly target: McpToolTarget;
45
45
  }
46
+ /**
47
+ * The argument an action that consumes an upload takes beside its input: the idempotency key the
48
+ * runtime requires of it, and the create intent the upload was begun with. The service removes it
49
+ * before the input is validated.
50
+ */
51
+ export declare const ACTION_OPTIONS_ARGUMENT = "_options";
46
52
  /**
47
53
  * The MCP tools of an ontology: `describe` and `me`, `aggregate` when the ontology has measures,
48
54
  * then for every stored entity `find`, `get`,
@@ -7,6 +7,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
7
7
  import { queryJsonSchema } from "../../query.js";
8
8
  import { measuresFor, RECORDS_OR_AGGREGATES } from "../../render.js";
9
9
  import { actionInputJsonSchema } from "../../runtime/generated-actions.js";
10
+ import { uploadFields } from "../../storage/attachments.js";
10
11
  import { actionDescription, requires } from "../descriptions.js";
11
12
  import { AGGREGATE_FILTER_OPERATORS, AGGREGATE_GRANULARITIES, AGGREGATE_LIMITS, HistoryPage, Me, } from "../schemas.js";
12
13
  const DEFS = "#/$defs/";
@@ -160,6 +161,37 @@ function readTools(planned, schemas, built) {
160
161
  },
161
162
  ];
162
163
  }
164
+ /**
165
+ * The argument an action that consumes an upload takes beside its input: the idempotency key the
166
+ * runtime requires of it, and the create intent the upload was begun with. The service removes it
167
+ * before the input is validated.
168
+ */
169
+ export const ACTION_OPTIONS_ARGUMENT = "_options";
170
+ const ACTION_OPTIONS_SCHEMA = {
171
+ type: "object",
172
+ description: "Options of an action that stores an uploaded file: idempotency_key, required with an upload, " +
173
+ "and create_intent, the one the upload was begun with when it creates a record.",
174
+ properties: {
175
+ idempotency_key: { type: "string", minLength: 1, maxLength: 128 },
176
+ create_intent: { type: "string", format: "uuid" },
177
+ },
178
+ additionalProperties: false,
179
+ };
180
+ /** The input schema of an action tool; one that consumes an upload also takes its options. */
181
+ function actionToolInput(action) {
182
+ const schema = rootObject(actionInputJsonSchema(action.input));
183
+ const properties = schema.properties;
184
+ if (uploadFields(action.input).length === 0 ||
185
+ schema.type !== "object" ||
186
+ properties === undefined ||
187
+ ACTION_OPTIONS_ARGUMENT in properties) {
188
+ return schema;
189
+ }
190
+ return {
191
+ ...schema,
192
+ properties: { ...properties, [ACTION_OPTIONS_ARGUMENT]: ACTION_OPTIONS_SCHEMA },
193
+ };
194
+ }
163
195
  function actionTool(planned, plannedAction) {
164
196
  const { name, action, permissions } = plannedAction;
165
197
  const hints = hintsOf(planned, plannedAction);
@@ -167,7 +199,7 @@ function actionTool(planned, plannedAction) {
167
199
  name: mcpToolName(planned.name, name),
168
200
  title: `${planned.resolved.entity.definition.label}: ${humanize(name).toLowerCase()}`,
169
201
  description: actionDescription(plannedAction),
170
- inputSchema: rootObject(actionInputJsonSchema(action.input)),
202
+ inputSchema: actionToolInput(action),
171
203
  annotations: {
172
204
  readOnlyHint: false,
173
205
  destructiveHint: hints.destructive,
@@ -1,4 +1,5 @@
1
1
  import { Context, Schema, type Effect, type Layer, type Redacted } from "effect";
2
+ import type { AttachmentRuntime } from "../storage/attachments.js";
2
3
  import type { BlobRuntime } from "../storage/sessions.js";
3
4
  import type { BlobStorageBinding } from "../storage/provider.js";
4
5
  import type { AnyAction } from "../action.js";
@@ -247,6 +248,8 @@ export interface AgentStore {
247
248
  }
248
249
  declare const OntologyBackend_base: Context.ServiceClass<OntologyBackend, "@segmentstream-cloud/ontology/OntologyBackend", {
249
250
  readonly blobs?: BlobRuntime;
251
+ /** The agent's temporary attachments, on deployments with file storage. */
252
+ readonly attachments?: AttachmentRuntime;
250
253
  readonly maintainBlobs?: Effect.Effect<{
251
254
  readonly removed: number;
252
255
  }, unknown>;
@@ -4,6 +4,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi";
4
4
  import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-auth.js";
5
5
  import { nameToWire } from "../authoring-names.js";
6
6
  import { sqlServiceRuntime } from "../runtime/service-binding.js";
7
+ import { attachmentsLayer } from "./agent/attachments.js";
7
8
  import { deployedAgentChannel } from "./agent/channel.js";
8
9
  import { agentLayer } from "./agent/server.js";
9
10
  import { makeAggregates } from "./aggregate.js";
@@ -116,7 +117,16 @@ export function createOntologyService(ontology, config) {
116
117
  agentChannel,
117
118
  }), oauthLayer({ verifyToken }));
118
119
  const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
119
- const application = Layer.mergeAll(routes, mcp, agent, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
120
+ const attachments = attachmentsLayer({
121
+ plan: built.plan,
122
+ hooks,
123
+ connections,
124
+ replicate,
125
+ agentChannel,
126
+ verifyToken,
127
+ verifyRuntime,
128
+ });
129
+ const application = Layer.mergeAll(routes, mcp, agent, attachments, DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
120
130
  const web = HttpRouter.toWebHandler(application, { disableLogger: true });
121
131
  const hash = ontology.manifest.hash;
122
132
  return {