@segmentstream-cloud/ontology 0.14.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.
Files changed (52) hide show
  1. package/README.md +74 -10
  2. package/dist/agent-chat.d.ts +75 -0
  3. package/dist/agent-chat.js +176 -0
  4. package/dist/analyze.d.ts +6 -2
  5. package/dist/analyze.js +29 -18
  6. package/dist/authoring.d.ts +6 -3
  7. package/dist/authoring.js +6 -3
  8. package/dist/index.d.ts +4 -2
  9. package/dist/index.js +2 -1
  10. package/dist/migrate/apply.js +2 -0
  11. package/dist/migrate/plan.js +14 -4
  12. package/dist/permissions.d.ts +11 -2
  13. package/dist/permissions.js +15 -3
  14. package/dist/replication/baseline.js +1 -1
  15. package/dist/replication/model.js +2 -2
  16. package/dist/replication/schema.js +1 -1
  17. package/dist/runtime/agents.d.ts +1 -0
  18. package/dist/runtime/agents.js +160 -0
  19. package/dist/runtime/generated-actions.d.ts +2 -1
  20. package/dist/runtime/generated-actions.js +18 -13
  21. package/dist/runtime/service-binding.js +3 -0
  22. package/dist/service/agent/attachments.d.ts +37 -0
  23. package/dist/service/agent/attachments.js +225 -0
  24. package/dist/service/agent/channel.d.ts +16 -0
  25. package/dist/service/agent/channel.js +88 -0
  26. package/dist/service/agent/protocol.d.ts +14 -0
  27. package/dist/service/agent/protocol.js +26 -0
  28. package/dist/service/agent/server.d.ts +2 -1
  29. package/dist/service/agent/server.js +24 -2
  30. package/dist/service/aggregate-platform.d.ts +5 -0
  31. package/dist/service/aggregate-platform.js +1 -1
  32. package/dist/service/config.d.ts +10 -0
  33. package/dist/service/config.js +8 -0
  34. package/dist/service/handlers.d.ts +2 -1
  35. package/dist/service/handlers.js +2 -2
  36. package/dist/service/mcp/server.d.ts +3 -0
  37. package/dist/service/mcp/server.js +27 -4
  38. package/dist/service/mcp/tools.d.ts +6 -0
  39. package/dist/service/mcp/tools.js +33 -1
  40. package/dist/service/operations.d.ts +6 -2
  41. package/dist/service/operations.js +13 -5
  42. package/dist/service/runtime-contract.d.ts +29 -0
  43. package/dist/service/service.js +18 -2
  44. package/dist/storage/attachments.d.ts +117 -0
  45. package/dist/storage/attachments.js +536 -0
  46. package/dist/storage/maintenance.d.ts +4 -1
  47. package/dist/storage/maintenance.js +7 -2
  48. package/dist/storage/neon.js +14 -0
  49. package/dist/storage/provider.d.ts +2 -0
  50. package/dist/storage/tables.d.ts +8 -0
  51. package/dist/storage/tables.js +32 -0
  52. package/package.json +1 -1
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ import { AgentChannel } from "../../agent-chat.js";
2
+ import type { AgentStore } from "../runtime-contract.js";
3
+ /** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
4
+ export declare const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
5
+ /** The agent of one action request: its token and the agent tables. */
6
+ export type AgentChannelFactory = (input: {
7
+ /** The deployment's Vercel OIDC token, from the request or the environment. */
8
+ readonly identityToken: string | undefined;
9
+ readonly store: AgentStore | undefined;
10
+ }) => AgentChannel["Service"];
11
+ /**
12
+ * The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
13
+ * goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
14
+ * verifies as its own project's. A member's private chat is their live Telegram identity.
15
+ */
16
+ export declare function deployedAgentChannel(agentUrl: string, fetchImpl?: typeof fetch): AgentChannelFactory;
@@ -0,0 +1,88 @@
1
+ import { Effect, Option, Schema } from "effect";
2
+ import { TELEGRAM } from "../../agent-chat.js";
3
+ import { ActionRejected } from "../../errors.js";
4
+ import { describeError } from "../aggregate-platform.js";
5
+ /** The agent's route for messages the ontology asks it to deliver, on the production deployment. */
6
+ export const AGENT_MESSAGES_PATH = "/eve/v1/telegram/messages";
7
+ /** How long the agent may take to accept a message; it answers once the message is queued. */
8
+ const DELIVERY_TIMEOUT_MS = 30_000;
9
+ /** The agent's error body: `{ "error": "channels_disabled" }` and the like. */
10
+ const AgentError = Schema.Struct({
11
+ error: Schema.optionalKey(Schema.String),
12
+ detail: Schema.optionalKey(Schema.String),
13
+ });
14
+ const rejected = (message) => new ActionRejected({ message });
15
+ const UNREACHABLE = "The agent could not be reached, so the message was not delivered. Try again.";
16
+ /**
17
+ * A delivery that got no answer (DNS, TLS, a timeout, a dropped body): the caught error is logged
18
+ * for the operator, with the token redacted, and the caller is told only that the agent was
19
+ * unreachable.
20
+ */
21
+ function deliveryFailed(error, token) {
22
+ const diagnostic = error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "unreachable";
23
+ const redact = (text) => text.split(token).join("[redacted]");
24
+ return Effect.logWarning("ontology.agent.delivery_failed").pipe(Effect.annotateLogs({ diagnostic, error: describeError(error, redact) }), Effect.andThen(Effect.fail(rejected(UNREACHABLE))));
25
+ }
26
+ /**
27
+ * What the caller is told when the agent does not accept the message, by its status and body. The
28
+ * status and the agent's error code are logged; the body is not.
29
+ */
30
+ function refusal(status, text) {
31
+ const body = Option.getOrElse(Schema.decodeUnknownOption(Schema.fromJsonString(AgentError))(text), () => ({}));
32
+ return Effect.logWarning("ontology.agent.delivery_refused").pipe(Effect.annotateLogs({ status, diagnostic: body.error ?? "refused" }), Effect.andThen(Effect.fail(refusalMessage(status, body))));
33
+ }
34
+ /** The caller's message for a refusal, by the agent's status and decoded error body. */
35
+ function refusalMessage(status, body) {
36
+ switch (status) {
37
+ case 400:
38
+ return rejected(`The agent refused the message as invalid${body.detail ? `: ${body.detail}` : "."}`);
39
+ case 401:
40
+ case 403:
41
+ return rejected("The agent refused the deployment's token, so the message was not delivered.");
42
+ case 404:
43
+ return rejected("The deployed agent does not take messages from the ontology. Redeploy the agent with an " +
44
+ "SDK that does.");
45
+ case 503:
46
+ return body.error === "channels_disabled"
47
+ ? rejected("The agent's channels run only in the production deployment, so it cannot deliver " +
48
+ "the message from here.")
49
+ : rejected("The agent is temporarily unavailable, so the message was not delivered. Try again.");
50
+ default:
51
+ return rejected(UNREACHABLE);
52
+ }
53
+ }
54
+ /**
55
+ * The agent of a deployment that has one, at `https://<VERCEL_PROJECT_PRODUCTION_URL>`. Each message
56
+ * goes to {@link AGENT_MESSAGES_PATH} with the deployment's Vercel OIDC token, which the agent
57
+ * verifies as its own project's. A member's private chat is their live Telegram identity.
58
+ */
59
+ export function deployedAgentChannel(agentUrl, fetchImpl = fetch) {
60
+ const url = `${agentUrl}${AGENT_MESSAGES_PATH}`;
61
+ return ({ identityToken, store }) => ({
62
+ privateChat: (userId) => store === undefined
63
+ ? Effect.succeed(undefined)
64
+ : store
65
+ .listIdentities(userId)
66
+ .pipe(Effect.map((identities) => identities.find((identity) => identity.provider === TELEGRAM)?.externalId)),
67
+ send: (message) => identityToken === undefined || identityToken === ""
68
+ ? Effect.fail(rejected("The request carries no Vercel OIDC token, which the agent requires. Messages are " +
69
+ "sent from the production deployment."))
70
+ : Effect.tryPromise({
71
+ try: async (signal) => {
72
+ const response = await fetchImpl(url, {
73
+ method: "POST",
74
+ headers: {
75
+ authorization: `Bearer ${identityToken}`,
76
+ "content-type": "application/json",
77
+ accept: "application/json",
78
+ },
79
+ body: JSON.stringify(message),
80
+ redirect: "error",
81
+ signal: AbortSignal.any([signal, AbortSignal.timeout(DELIVERY_TIMEOUT_MS)]),
82
+ });
83
+ return { ok: response.ok, status: response.status, text: await response.text() };
84
+ },
85
+ catch: (error) => error,
86
+ }).pipe(Effect.catch((error) => deliveryFailed(error, identityToken)), Effect.flatMap((answer) => answer.ok ? Effect.void : refusal(answer.status, answer.text))),
87
+ });
88
+ }
@@ -26,6 +26,20 @@ export declare const PairingRequest: Schema.Struct<{
26
26
  readonly display: Schema.optionalKey<Schema.NullOr<Schema.String>>;
27
27
  }>;
28
28
  export type PairingRequest = typeof PairingRequest.Type;
29
+ /**
30
+ * `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
31
+ * the paired member who left it, or the agent itself leaving it, or the group it was before it
32
+ * became a supergroup.
33
+ */
34
+ export declare const ChatSeenRequest: Schema.Struct<{
35
+ readonly provider: Schema.String;
36
+ readonly externalId: Schema.String;
37
+ readonly title: Schema.String;
38
+ readonly memberId: Schema.optionalKey<Schema.String>;
39
+ readonly leftMemberId: Schema.optionalKey<Schema.String>;
40
+ readonly agentLeft: Schema.optionalKey<Schema.Literal<true>>;
41
+ readonly migratedFrom: Schema.optionalKey<Schema.String>;
42
+ }>;
29
43
  /** An identity on the wire, as the member's connect page and settings show it. */
30
44
  export declare function identityForWire(identity: AgentIdentity): {
31
45
  id: string;
@@ -40,6 +40,32 @@ export const PairingRequest = Schema.Struct({
40
40
  description: "How the channel names the account, shown to the member who approves.",
41
41
  })),
42
42
  });
43
+ /** A chat's id on the channel. */
44
+ const ChatId = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(MAX_EXTERNAL_ID_LENGTH));
45
+ /**
46
+ * `POST /api/ontology/agent/chats/seen`: a group chat the agent saw a message in, and who wrote, or
47
+ * the paired member who left it, or the agent itself leaving it, or the group it was before it
48
+ * became a supergroup.
49
+ */
50
+ export const ChatSeenRequest = Schema.Struct({
51
+ provider: Provider,
52
+ externalId: ChatId.annotate({ description: "The chat's id on the channel." }),
53
+ title: Schema.String.check(Schema.isMaxLength(MAX_DISPLAY_LENGTH)).annotate({
54
+ description: "The chat's name on the channel.",
55
+ }),
56
+ memberId: Schema.optionalKey(Schema.String.annotate({
57
+ description: "The user id of the member who wrote, when the sender is paired.",
58
+ })),
59
+ leftMemberId: Schema.optionalKey(Schema.String.annotate({
60
+ description: "The user id of a paired member who left the chat, to unlink from its members.",
61
+ })),
62
+ agentLeft: Schema.optionalKey(Schema.Literal(true).annotate({
63
+ description: "The agent itself left the chat or was removed: every member is unlinked.",
64
+ })),
65
+ migratedFrom: Schema.optionalKey(ChatId.annotate({
66
+ description: "The chat's id before it became a supergroup: its record moves to externalId.",
67
+ })),
68
+ });
43
69
  /** An identity on the wire, as the member's connect page and settings show it. */
44
70
  export function identityForWire(identity) {
45
71
  return {
@@ -19,7 +19,8 @@ export interface AgentServerOptions {
19
19
  readonly exchangeSession: SessionExchange | undefined;
20
20
  }
21
21
  /**
22
- * The routes that pair channel accounts with members for the app's own agent. Runtime routes take
22
+ * The routes that pair channel accounts with members for the app's own agent, and record the group
23
+ * chats it sees. Runtime routes take
23
24
  * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
24
25
  * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
25
26
  * Without the agent tables, it serves nothing.
@@ -10,11 +10,12 @@ import { secretHash } from "../oauth/protocol.js";
10
10
  import { clientAddress, fixedWindowLimiter, } from "../rate-limit.js";
11
11
  import { OntologyBackend } from "../runtime-contract.js";
12
12
  import { logRuntimeRejection } from "../runtime-identity.js";
13
- import { PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
13
+ import { ChatSeenRequest, PAIRING_TTL_MS, PairingRequest, canonicalPairingCode, identityForWire, memberForWire, newPairingCode, } from "./protocol.js";
14
14
  /** The agent's endpoints, under the ontology service's path. */
15
15
  export const AGENT_PATH = `${BASE_PATH}/agent`;
16
16
  const PAIRINGS_PATH = `${AGENT_PATH}/pairings`;
17
17
  const IDENTITIES_PATH = `${AGENT_PATH}/identities`;
18
+ const CHATS_SEEN_PATH = `${AGENT_PATH}/chats/seen`;
18
19
  /**
19
20
  * Pairings one client address may start per minute; each one is a row in the database. The agent's
20
21
  * functions share a few egress addresses, so the limit leaves room for a busy channel.
@@ -153,6 +154,25 @@ function resolveIdentity(store) {
153
154
  return json(memberForWire(member));
154
155
  });
155
156
  }
157
+ /**
158
+ * `POST /api/ontology/agent/chats/seen` (runtime): the agent saw a message in a group chat. The chat
159
+ * is recorded, or its title kept current, a paired writer joins its members and a paired member who
160
+ * left is unlinked from them, or all of them when the agent itself left; the answer says what the
161
+ * chat is for, so that the agent knows it.
162
+ */
163
+ function seeChat(store) {
164
+ return (request) => Effect.gen(function* () {
165
+ const requestId = yield* RequestId;
166
+ const decoded = Schema.decodeUnknownResult(ChatSeenRequest)(yield* jsonBody(request), {
167
+ errors: "all",
168
+ onExcessProperty: "error",
169
+ });
170
+ if (Result.isFailure(decoded)) {
171
+ return invalidBody(requestId, schemaIssues(decoded.failure));
172
+ }
173
+ return json(yield* store.seeChat(decoded.success));
174
+ });
175
+ }
156
176
  /** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
157
177
  function listIdentities(store, member) {
158
178
  return Effect.gen(function* () {
@@ -174,7 +194,8 @@ function revokeIdentity(store, member) {
174
194
  });
175
195
  }
176
196
  /**
177
- * The routes that pair channel accounts with members for the app's own agent. Runtime routes take
197
+ * The routes that pair channel accounts with members for the app's own agent, and record the group
198
+ * chats it sees. Runtime routes take
178
199
  * only the deployment's runtime token. Member routes take a member's Neon Auth session or token, and
179
200
  * refuse API keys and the runtime, even acting as a member: only the member pairs or unpairs.
180
201
  * Without the agent tables, it serves nothing.
@@ -230,6 +251,7 @@ export function agentLayer(options) {
230
251
  yield* router.add("POST", `${PAIRINGS_PATH}/:code/approve`, asMember((_, member) => approvePairing(store, member)));
231
252
  yield* router.add("GET", IDENTITIES_PATH, asMember((_, member) => listIdentities(store, member)));
232
253
  yield* router.add("GET", `${IDENTITIES_PATH}/:provider/:external_id`, asRuntime(() => resolveIdentity(store)));
254
+ yield* router.add("POST", CHATS_SEEN_PATH, asRuntime(seeChat(store)));
233
255
  yield* router.add("DELETE", `${IDENTITIES_PATH}/:id`, asMember((_, member) => revokeIdentity(store, member)));
234
256
  }));
235
257
  }
@@ -47,6 +47,11 @@ export interface AggregateEnvironment {
47
47
  readonly appCredential?: Redacted.Redacted<string>;
48
48
  readonly manifestHash: string;
49
49
  }
50
+ /**
51
+ * A failed call for the operator: name, message and stack, then each cause, with every occurrence
52
+ * of the credential replaced.
53
+ */
54
+ export declare function describeError(error: unknown, redact: (text: string) => string, depth?: number): string;
50
55
  /**
51
56
  * SegmentStream's `POST /api/v1/workspaces/{id}/aggregate`, and its per-entity
52
57
  * `…/entities/{entity}/aggregate`, called with the workspace app credential. `undefined` when the deployment has none, or when SegmentStream's origin is plain
@@ -58,7 +58,7 @@ function securePlatform(origin) {
58
58
  * A failed call for the operator: name, message and stack, then each cause, with every occurrence
59
59
  * of the credential replaced.
60
60
  */
61
- function describeError(error, redact, depth = 0) {
61
+ export function describeError(error, redact, depth = 0) {
62
62
  if (!(error instanceof Error)) {
63
63
  return redact(String(error));
64
64
  }
@@ -37,6 +37,11 @@ export declare const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES: {
37
37
  readonly audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE";
38
38
  readonly projectId: "VERCEL_PROJECT_ID";
39
39
  };
40
+ /**
41
+ * The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
42
+ * takes the messages of `agent_chat.send_message` there.
43
+ */
44
+ export declare const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
40
45
  /** Where the workspace's Neon Auth issues and publishes the keys of its users' tokens. */
41
46
  export interface NeonAuthEnvironment {
42
47
  /** The Auth URL, such as `https://ep-….neon.tech/ontology/auth`. Its origin is the issuer. */
@@ -68,6 +73,11 @@ export interface OntologyEnvironment {
68
73
  * authenticate, acting as the member `X-SegmentStream-Act-As` names.
69
74
  */
70
75
  readonly runtimeIdentity?: RuntimeIdentityConfig;
76
+ /**
77
+ * The agent's origin, `https://<VERCEL_PROJECT_PRODUCTION_URL>`, set with `runtimeIdentity`:
78
+ * `agent_chat.send_message` hands messages to the agent there.
79
+ */
80
+ readonly agentUrl?: string;
71
81
  /**
72
82
  * The local grant `segmentstream exec --connection …` passed, in development. Without it, actions
73
83
  * call connections through SegmentStream with the deployment's Vercel OIDC token.
@@ -34,6 +34,11 @@ export const RUNTIME_IDENTITY_ENVIRONMENT_VARIABLES = {
34
34
  audience: "SEGMENTSTREAM_RUNTIME_OIDC_AUDIENCE",
35
35
  projectId: "VERCEL_PROJECT_ID",
36
36
  };
37
+ /**
38
+ * The project's production domain, which Vercel sets: the agent of a deployment with runtime identity
39
+ * takes the messages of `agent_chat.send_message` there.
40
+ */
41
+ export const AGENT_HOST_VARIABLE = "VERCEL_PROJECT_PRODUCTION_URL";
37
42
  /** The service cannot start: its configuration is incomplete or invalid. */
38
43
  export class OntologyServiceConfigError extends Schema.TaggedError()("OntologyServiceConfigError", { message: Schema.String }) {
39
44
  }
@@ -160,6 +165,9 @@ export function configFromEnv(environment = process.env) {
160
165
  : {}),
161
166
  ...(neonAuth === undefined ? {} : { neonAuth }),
162
167
  ...(runtimeIdentity === undefined ? {} : { runtimeIdentity }),
168
+ ...(runtimeIdentity === undefined || read(AGENT_HOST_VARIABLE) === ""
169
+ ? {}
170
+ : { agentUrl: normalizeUrl(AGENT_HOST_VARIABLE, `https://${read(AGENT_HOST_VARIABLE)}`) }),
163
171
  ...(localConnections === undefined ? {} : { localConnections }),
164
172
  };
165
173
  }
@@ -4,6 +4,7 @@ import type { Aggregates } from "./aggregate.js";
4
4
  import { type BuiltApi } from "./api.js";
5
5
  import type { AfterCommitHook } from "./config.js";
6
6
  import type { ReplicationKick } from "./replication.js";
7
+ import type { AgentChannelFactory } from "./agent/channel.js";
7
8
  import type { ConnectionTransportFactory } from "./connections.js";
8
9
  import { type RateLimit } from "./rate-limit.js";
9
10
  import { OntologyBackend } from "./runtime-contract.js";
@@ -13,4 +14,4 @@ import { OntologyBackend } from "./runtime-contract.js";
13
14
  */
14
15
  export declare const SIGN_IN_CHECK_LIMIT: RateLimit;
15
16
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
16
- export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
17
+ export declare function handlersLayer(built: BuiltApi, hooks: ReadonlyMap<string, AfterCommitHook>, connections: ConnectionTransportFactory, replicate: ReplicationKick, aggregates: Aggregates, agentChannel: AgentChannelFactory | undefined): Layer.Layer<HttpApiGroup.Service<"ontology", string>, never, OntologyBackend>;
@@ -216,8 +216,8 @@ function storageHandlers(built) {
216
216
  }));
217
217
  }
218
218
  /** The handlers of every group of the API; they require {@link OntologyBackend}. */
219
- export function handlersLayer(built, hooks, connections, replicate, aggregates) {
220
- const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate);
219
+ export function handlersLayer(built, hooks, connections, replicate, aggregates, agentChannel) {
220
+ const operationsOf = (backend) => makeOperations(backend, built.plan, hooks, connections, replicate, agentChannel);
221
221
  const layers = [
222
222
  serviceHandlers(built, operationsOf, aggregates.model),
223
223
  ...(built.plan.ontology.manifest.entities.some((entity) => entity.properties.some((property) => property.type === "blob"))
@@ -4,6 +4,7 @@ import type { Aggregates } from "../aggregate.js";
4
4
  import type { BuiltApi } from "../api.js";
5
5
  import { type TokenVerifier } from "../authentication.js";
6
6
  import type { AfterCommitHook } from "../config.js";
7
+ import type { AgentChannelFactory } from "../agent/channel.js";
7
8
  import type { ConnectionTransportFactory } from "../connections.js";
8
9
  import { RequestId } from "../errors.js";
9
10
  import type { ReplicationKick } from "../replication.js";
@@ -23,6 +24,8 @@ export interface McpServerOptions {
23
24
  readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
24
25
  /** The facade the aggregate routes run; the aggregate tool runs its model-level aggregate. */
25
26
  readonly aggregates: Aggregates;
27
+ /** The workspace's agent, for `agent_chat.send_message`; `undefined` without one. */
28
+ readonly agentChannel?: AgentChannelFactory | undefined;
26
29
  }
27
30
  /**
28
31
  * The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
@@ -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));
@@ -417,7 +440,7 @@ export function mcpLayer(options) {
417
440
  const server = {
418
441
  built: options.built,
419
442
  tools,
420
- operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate),
443
+ operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
421
444
  aggregate: options.aggregates.model,
422
445
  };
423
446
  yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
@@ -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`,