@segmentstream-cloud/ontology 0.19.0 → 0.21.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 +25 -1
- package/dist/agent-chat.d.ts +3 -0
- package/dist/agent-chat.js +25 -1
- package/dist/runtime/files.d.ts +39 -0
- package/dist/runtime/files.js +44 -0
- package/dist/runtime/pipeline.js +4 -1
- package/dist/service/agent/attachments.d.ts +2 -5
- package/dist/service/agent/attachments.js +13 -42
- package/dist/service/agent/authentication.d.ts +20 -0
- package/dist/service/agent/authentication.js +48 -0
- package/dist/service/agent/conversation-routes.d.ts +14 -0
- package/dist/service/agent/conversation-routes.js +43 -0
- package/dist/service/agent/conversations.d.ts +35 -0
- package/dist/service/agent/conversations.js +68 -0
- package/dist/service/agent/files.d.ts +12 -0
- package/dist/service/agent/files.js +69 -0
- package/dist/service/agent/server.js +7 -1
- package/dist/service/errors.js +11 -0
- package/dist/service/mcp/server.d.ts +2 -0
- package/dist/service/mcp/server.js +32 -5
- package/dist/service/mcp/tools.d.ts +3 -1
- package/dist/service/mcp/tools.js +45 -1
- package/dist/service/service.js +13 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -370,7 +370,7 @@ both are recorded, moves the members and a missing `purpose` and `whenToUse` to
|
|
|
370
370
|
deletes the old one. `provider`, `externalId`, `title` and `lastMessageAt` are
|
|
371
371
|
managed. Members set `purpose` and `whenToUse` with the generated
|
|
372
372
|
`agent_chat.update`, the chat's only generated action. The built-in action
|
|
373
|
-
`agent_chat.send_message` (`{ message, chat?, person? }`, `message` at most 4000 characters) asks the agent to write to the chat
|
|
373
|
+
`agent_chat.send_message` (`{ message, chat?, person?, document_ids? }`, `message` at most 4000 characters) asks the agent to write to the chat
|
|
374
374
|
given, else to the person's one chat, else to their paired private chat; a person in several
|
|
375
375
|
chats is refused with the candidates, or only their number without `agent_chat:read`. The service posts the message to the agent at
|
|
376
376
|
`https://<VERCEL_PROJECT_PRODUCTION_URL>/eve/v1/telegram/messages` with the deployment's OIDC
|
|
@@ -382,6 +382,30 @@ chats needs `agent_chat:read` by name: `*:read` and `*:*` do not cover `agent_ch
|
|
|
382
382
|
|
|
383
383
|
### Agent attachments
|
|
384
384
|
|
|
385
|
+
Ontology SDK 0.21.0 adds `documentIds` to the authored action input (`document_ids` on REST/MCP):
|
|
386
|
+
an optional array of at most ten `document` record UUIDs. Before posting anything to the agent,
|
|
387
|
+
the action checks `document:read`, every record's existence and its single file property.
|
|
388
|
+
Missing files refuse the request; unreadable files require the read permission; ambiguous file
|
|
389
|
+
properties are refused. Requests without the field retain their previous behavior. The service
|
|
390
|
+
requires a paired member requester for requests with documents, since the agent downloads as
|
|
391
|
+
that member; services and unpaired requesters may still send requests without documents. It
|
|
392
|
+
forwards `documentIds` to `POST /eve/v1/telegram/messages`; agent SDK 0.5.0 includes the ids in
|
|
393
|
+
`<agent_request>` and sends the stored bytes with `segmentstream__attachments_send`.
|
|
394
|
+
|
|
395
|
+
`GET /api/ontology/agent/files/:entity/:id[/:property]` returns `{ file, download }` for the
|
|
396
|
+
current file property of an ontology record. Like temporary attachment routes, it requires a
|
|
397
|
+
production runtime bearer token, `X-SegmentStream-Act-As` naming an active paired member,
|
|
398
|
+
and the agent conversation/session headers. It checks `<entity>:read` before returning metadata
|
|
399
|
+
and rechecks live permissions when granting a download pinned to that revision. The response is
|
|
400
|
+
uncached; the download grant is short lived. Without a property the entity must have exactly one
|
|
401
|
+
file property. Missing records/files return `not_found`, missing read access `forbidden`, and
|
|
402
|
+
non-file or ambiguous properties `unsupported`.
|
|
403
|
+
|
|
404
|
+
Outbound Telegram uploads currently have an effective **2 MiB multipart request limit** in the
|
|
405
|
+
connection proxy, including overhead, even though Telegram accepts larger files. The agent splits
|
|
406
|
+
compatible albums to fit and returns per-file errors. Group purpose and document restrictions
|
|
407
|
+
still apply; a member's read access alone does not authorize sharing a restricted document in a group.
|
|
408
|
+
|
|
385
409
|
A file a member sends the agent is a temporary attachment: platform metadata in the runtime
|
|
386
410
|
table `_agent_attachments`, never an ontology record, with its bytes stored once as a revision in
|
|
387
411
|
the ontology's file storage, beside uploads. It belongs to the member, the chat it arrived in and
|
package/dist/agent-chat.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export interface AgentMessage {
|
|
|
14
14
|
};
|
|
15
15
|
/** The request for the agent, written as an instruction to it. */
|
|
16
16
|
readonly message: string;
|
|
17
|
+
/** Document record ids to send with the request; absent from older requests. */
|
|
18
|
+
readonly documentIds?: ReadonlyArray<string>;
|
|
17
19
|
/** Whom the agent should address, when the caller named someone. */
|
|
18
20
|
readonly person: {
|
|
19
21
|
readonly id: string;
|
|
@@ -63,6 +65,7 @@ export declare const AgentChat: import("./entity.js").Entity<"agent_chat", {
|
|
|
63
65
|
}, {
|
|
64
66
|
readonly sendMessage: import("./action.js").Action<Schema.Struct<{
|
|
65
67
|
readonly message: import("./property.js").Property<string, false, false>;
|
|
68
|
+
readonly documentIds: Schema.optionalKey<Schema.$Array<Schema.String>>;
|
|
66
69
|
readonly chat: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
|
|
67
70
|
readonly person: Schema.optionalKey<Schema.Codec<string, string, never, never>>;
|
|
68
71
|
}>, {
|
package/dist/agent-chat.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "./permissions.js";
|
|
|
6
6
|
import { p } from "./property.js";
|
|
7
7
|
import { rel } from "./relationship.js";
|
|
8
8
|
import { User } from "./user.js";
|
|
9
|
+
import { AgentFileRecords } from "./runtime/files.js";
|
|
9
10
|
/** The only channel the agent delivers to so far, and the provider of its chats. */
|
|
10
11
|
export const TELEGRAM = "telegram";
|
|
11
12
|
/**
|
|
@@ -84,12 +85,16 @@ export const AgentChat = entity("agent_chat", {
|
|
|
84
85
|
"chats is refused with the candidates (id, title, purpose, when to use; only their number " +
|
|
85
86
|
"without agent_chat:read): call again with chat. The agent then writes in that chat and " +
|
|
86
87
|
"handles the replies there, in that chat's own session; this action only delivers the " +
|
|
87
|
-
"request and does not wait for an answer.
|
|
88
|
+
"request and does not wait for an answer. document_ids optionally names up to ten readable " +
|
|
89
|
+
"document records to attach with the question. Requires an agent deployed in the workspace.",
|
|
88
90
|
input: Schema.Struct({
|
|
89
91
|
message: p.text({
|
|
90
92
|
maxLength: 4000,
|
|
91
93
|
description: "The request for the agent, as an instruction: what to ask or tell, of whom.",
|
|
92
94
|
}),
|
|
95
|
+
documentIds: Schema.optionalKey(Schema.Array(Schema.String.check(Schema.isUUID()))
|
|
96
|
+
.check(Schema.isMaxLength(10))
|
|
97
|
+
.annotate({ description: "Up to ten document record ids the requester may read." })),
|
|
93
98
|
chat: p.ref("agent_chat", { optional: true, description: "The agent chat to write in." }),
|
|
94
99
|
person: p.ref("user", {
|
|
95
100
|
optional: true,
|
|
@@ -103,6 +108,19 @@ export const AgentChat = entity("agent_chat", {
|
|
|
103
108
|
if (Option.isNone(channel)) {
|
|
104
109
|
return yield* new ActionRejected({ message: NO_AGENT });
|
|
105
110
|
}
|
|
111
|
+
if (input.documentIds !== undefined && input.documentIds.length > 0) {
|
|
112
|
+
const files = yield* Effect.serviceOption(AgentFileRecords);
|
|
113
|
+
if (Option.isNone(files)) {
|
|
114
|
+
return yield* new ActionRejected({
|
|
115
|
+
message: "This runtime cannot validate stored documents.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
for (const id of input.documentIds) {
|
|
119
|
+
yield* files.value
|
|
120
|
+
.resolve({ entity: "document", id }, principal)
|
|
121
|
+
.pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.fail(new ActionRejected({ message: error.message }))));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
106
124
|
const person = input.person === undefined ? undefined : yield* User.get(input.person);
|
|
107
125
|
if (person !== undefined && (person.kind !== "member" || !person.active)) {
|
|
108
126
|
return yield* new ActionRejected({
|
|
@@ -158,9 +176,15 @@ export const AgentChat = entity("agent_chat", {
|
|
|
158
176
|
// The agent acts as a member only through their pairing.
|
|
159
177
|
const actAs = principal.kind === "member" &&
|
|
160
178
|
(yield* channel.value.privateChat(principal.id)) !== undefined;
|
|
179
|
+
if ((input.documentIds?.length ?? 0) > 0 && !actAs) {
|
|
180
|
+
return yield* new ActionRejected({
|
|
181
|
+
message: "Sending documents requires a member requester with a paired Telegram account.",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
161
184
|
yield* channel.value.send({
|
|
162
185
|
target,
|
|
163
186
|
message: input.message,
|
|
187
|
+
...(input.documentIds === undefined ? {} : { documentIds: input.documentIds }),
|
|
164
188
|
person: person === undefined ? null : { id: person.id, name: person.name },
|
|
165
189
|
requester: {
|
|
166
190
|
id: requester.id,
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Context, Effect, Schema } from "effect";
|
|
2
|
+
import { NotFound, PermissionDenied } from "../errors.js";
|
|
3
|
+
import type { OntologyManifest } from "../manifest.js";
|
|
4
|
+
import type { OntologyReads, Principal } from "../runtime.js";
|
|
5
|
+
export interface FileReference {
|
|
6
|
+
readonly entity: string;
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly property?: string;
|
|
9
|
+
}
|
|
10
|
+
declare const UnsupportedFile_base: Schema.Class<UnsupportedFile, Schema.TaggedStruct<"UnsupportedFile", {
|
|
11
|
+
readonly message: Schema.String;
|
|
12
|
+
}>, import("effect/Cause").YieldableError>;
|
|
13
|
+
export declare class UnsupportedFile extends UnsupportedFile_base {
|
|
14
|
+
}
|
|
15
|
+
/** Resolve only declared file properties, checking permission before reading any record. */
|
|
16
|
+
export declare function resolveFileRecord(input: {
|
|
17
|
+
readonly manifest: OntologyManifest;
|
|
18
|
+
readonly reads: Pick<OntologyReads["Service"], "get">;
|
|
19
|
+
readonly principal: Principal;
|
|
20
|
+
readonly reference: FileReference;
|
|
21
|
+
}): Effect.Effect<{
|
|
22
|
+
entity: string;
|
|
23
|
+
id: string;
|
|
24
|
+
property: string;
|
|
25
|
+
file: {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly filename: string;
|
|
28
|
+
readonly contentType: string;
|
|
29
|
+
readonly size: number;
|
|
30
|
+
readonly createdAt: string;
|
|
31
|
+
};
|
|
32
|
+
}, PermissionDenied | NotFound | UnsupportedFile, never>;
|
|
33
|
+
declare const AgentFileRecords_base: Context.ServiceClass<AgentFileRecords, "@segmentstream-cloud/ontology/AgentFileRecords", {
|
|
34
|
+
readonly resolve: (reference: FileReference, principal: Principal) => ReturnType<typeof resolveFileRecord>;
|
|
35
|
+
}>;
|
|
36
|
+
/** File validation in an action's prepare, bound to its ontology and read runtime. */
|
|
37
|
+
export declare class AgentFileRecords extends AgentFileRecords_base {
|
|
38
|
+
}
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Context, Effect, Schema } from "effect";
|
|
2
|
+
import { BlobValue } from "../blob.js";
|
|
3
|
+
import { NotFound, PermissionDenied } from "../errors.js";
|
|
4
|
+
import { isAllowed, permissionOf, READ_VERB } from "../permissions.js";
|
|
5
|
+
export class UnsupportedFile extends Schema.TaggedError()("UnsupportedFile", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {
|
|
8
|
+
}
|
|
9
|
+
/** Resolve only declared file properties, checking permission before reading any record. */
|
|
10
|
+
export function resolveFileRecord(input) {
|
|
11
|
+
return Effect.gen(function* () {
|
|
12
|
+
const { entity: entityName, id, property: propertyName } = input.reference;
|
|
13
|
+
const permission = permissionOf(entityName, READ_VERB);
|
|
14
|
+
if (!isAllowed(permission, input.principal)) {
|
|
15
|
+
return yield* new PermissionDenied({ permission, message: "The file requires read access." });
|
|
16
|
+
}
|
|
17
|
+
const entity = input.manifest.entities.find((candidate) => candidate.name === entityName);
|
|
18
|
+
if (entity === undefined) {
|
|
19
|
+
return yield* new NotFound({ entity: entityName, id, message: "File record not found." });
|
|
20
|
+
}
|
|
21
|
+
const properties = entity.properties.filter((candidate) => candidate.type === "blob");
|
|
22
|
+
const property = propertyName === undefined
|
|
23
|
+
? properties.length === 1
|
|
24
|
+
? properties[0]
|
|
25
|
+
: undefined
|
|
26
|
+
: properties.find((candidate) => candidate.name === propertyName);
|
|
27
|
+
if (property === undefined) {
|
|
28
|
+
return yield* new UnsupportedFile({
|
|
29
|
+
message: "Name a file property; the entity must have exactly one when property is omitted.",
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const record = yield* input.reads.get({ entity: entityName, id });
|
|
33
|
+
const file = record[property.name];
|
|
34
|
+
if (file === null || file === undefined) {
|
|
35
|
+
return yield* new NotFound({ entity: entityName, id, message: "File not found." });
|
|
36
|
+
}
|
|
37
|
+
// Runtime records are decoded already; malformed stored metadata is an operator failure.
|
|
38
|
+
const decoded = yield* Schema.decodeUnknownEffect(BlobValue)(file).pipe(Effect.orDie);
|
|
39
|
+
return { entity: entityName, id, property: property.name, file: decoded };
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/** File validation in an action's prepare, bound to its ontology and read runtime. */
|
|
43
|
+
export class AgentFileRecords extends Context.Service()("@segmentstream-cloud/ontology/AgentFileRecords") {
|
|
44
|
+
}
|
package/dist/runtime/pipeline.js
CHANGED
|
@@ -14,6 +14,7 @@ import { UnknownAction, ValidationIssue } from "./errors.js";
|
|
|
14
14
|
import { generatedActions } from "./generated-actions.js";
|
|
15
15
|
import { Edit } from "./history.js";
|
|
16
16
|
import { makeOntologyRuntime } from "./sql-runtime.js";
|
|
17
|
+
import { AgentFileRecords, resolveFileRecord } from "./files.js";
|
|
17
18
|
import { validate } from "./validation.js";
|
|
18
19
|
/** How long `prepare` may take, connection calls included; each call has its own 30 s deadline. */
|
|
19
20
|
export const PREPARE_TIMEOUT = Duration.seconds(60);
|
|
@@ -243,7 +244,9 @@ function prepare(ontology, sql, action, context, connections, qualified) {
|
|
|
243
244
|
find: ({ entity, options }) => runtime.find({ entity, options: { ...options, forUpdate: false } }),
|
|
244
245
|
};
|
|
245
246
|
const run = Effect.suspend(() => action.prepare(context));
|
|
246
|
-
return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.
|
|
247
|
+
return yield* run.pipe(Effect.provideService(OntologyReads, reads), Effect.provideService(AgentFileRecords, {
|
|
248
|
+
resolve: (reference, principal) => resolveFileRecord({ manifest: ontology.manifest, reads, reference, principal }),
|
|
249
|
+
}), Effect.provideContext(connectionContext(action.connections, transport ?? NO_CONNECTIONS)), Effect.timeoutOrElse({
|
|
247
250
|
duration: PREPARE_TIMEOUT,
|
|
248
251
|
orElse: () => Effect.fail(new ConnectionError({
|
|
249
252
|
connection: action.connections.map((handle) => handle.key).join(","),
|
|
@@ -7,8 +7,9 @@ import { RequestId } from "../errors.js";
|
|
|
7
7
|
import type { ApiPlan } from "../plan.js";
|
|
8
8
|
import type { ReplicationKick } from "../replication.js";
|
|
9
9
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
10
|
-
import {
|
|
10
|
+
import type { RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
11
11
|
import type { AgentChannelFactory } from "./channel.js";
|
|
12
|
+
export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
12
13
|
/**
|
|
13
14
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
15
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
@@ -17,10 +18,6 @@ import type { AgentChannelFactory } from "./channel.js";
|
|
|
17
18
|
* tool arguments; every operation is confined to that member, chat and session.
|
|
18
19
|
*/
|
|
19
20
|
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
21
|
export interface AttachmentRoutesOptions {
|
|
25
22
|
readonly plan: ApiPlan;
|
|
26
23
|
readonly hooks: ReadonlyMap<string, AfterCommitHook>;
|
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
import { Cause, Effect, Layer,
|
|
2
|
-
import { HttpRouter,
|
|
3
|
-
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
1
|
+
import { Cause, Effect, Layer, Result, Schema } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
4
3
|
import { ATTACHMENT_SOURCES, } from "../../storage/attachments.js";
|
|
5
4
|
import { schemaIssues } from "../../runtime/validation.js";
|
|
6
5
|
import { CurrentPrincipal } from "../authentication.js";
|
|
7
|
-
import { RequestId, errorBodyForWire, failureResponse
|
|
6
|
+
import { RequestId, errorBodyForWire, failureResponse } from "../errors.js";
|
|
8
7
|
import { identityToken, makeOperations } from "../operations.js";
|
|
9
8
|
import { OntologyBackend } from "../runtime-contract.js";
|
|
10
|
-
import {
|
|
9
|
+
import { authenticateAgentCaller } from "./authentication.js";
|
|
11
10
|
import { AGENT_PATH } from "./server.js";
|
|
11
|
+
export { CONVERSATION_HEADER, SESSION_HEADER } from "./authentication.js";
|
|
12
12
|
/**
|
|
13
13
|
* The agent's temporary attachments over HTTP (`docs/agent.md`, "Attachments"). Only the
|
|
14
14
|
* deployment's runtime token reaches these routes, acting as the member in `X-SegmentStream-Act-As`
|
|
@@ -17,12 +17,6 @@ import { AGENT_PATH } from "./server.js";
|
|
|
17
17
|
* tool arguments; every operation is confined to that member, chat and session.
|
|
18
18
|
*/
|
|
19
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
20
|
const NO_STORE = { "cache-control": "no-store" };
|
|
27
21
|
const Received = Schema.Struct({
|
|
28
22
|
source: Schema.Literals(ATTACHMENT_SOURCES),
|
|
@@ -111,39 +105,16 @@ export function attachmentsLayer(options) {
|
|
|
111
105
|
return;
|
|
112
106
|
}
|
|
113
107
|
const operations = makeOperations(backend, options.plan, options.hooks, options.connections, options.replicate, options.agentChannel);
|
|
114
|
-
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
115
108
|
/** Authenticates the runtime, then the member it acts for, the chat and the session. */
|
|
116
109
|
const asAgent = (answer) => (request) => Effect.gen(function* () {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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);
|
|
110
|
+
const authenticated = yield* authenticateAgentCaller({
|
|
111
|
+
request,
|
|
112
|
+
agents,
|
|
113
|
+
verify: verifyRuntime,
|
|
114
|
+
});
|
|
115
|
+
return authenticated.ok
|
|
116
|
+
? yield* answer(request, authenticated.caller, attachments)
|
|
117
|
+
: authenticated.response;
|
|
147
118
|
});
|
|
148
119
|
const idOf = Effect.map(HttpRouter.params, (params) => params.id ?? "");
|
|
149
120
|
yield* router.add("POST", ATTACHMENTS_PATH, asAgent((request, caller, runtime) => answered(Effect.gen(function* () {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
import { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import type { AttachmentCaller } from "../../storage/attachments.js";
|
|
4
|
+
import { RequestId } from "../errors.js";
|
|
5
|
+
import type { AgentStore } from "../runtime-contract.js";
|
|
6
|
+
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
7
|
+
export declare const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
8
|
+
export declare const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
9
|
+
/** Both temporary attachments and stored files require the runtime and its paired member. */
|
|
10
|
+
export declare function authenticateAgentCaller(input: {
|
|
11
|
+
readonly request: HttpServerRequest.HttpServerRequest;
|
|
12
|
+
readonly agents: AgentStore;
|
|
13
|
+
readonly verify: RuntimeTokenVerifier;
|
|
14
|
+
}): Effect.Effect<{
|
|
15
|
+
readonly ok: false;
|
|
16
|
+
readonly response: import("effect/unstable/http/HttpServerResponse").HttpServerResponse;
|
|
17
|
+
} | {
|
|
18
|
+
readonly ok: true;
|
|
19
|
+
readonly caller: AttachmentCaller;
|
|
20
|
+
}, never, RequestId | HttpServerRequest.ParsedSearchParams>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Effect, Redacted } from "effect";
|
|
2
|
+
import { HttpServerRequest } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
+
import { RequestId, forbidden, toServerResponse, unauthorized, unavailable } from "../errors.js";
|
|
5
|
+
import { ACT_AS_HEADER, logRuntimeRejection, } from "../runtime-identity.js";
|
|
6
|
+
export const CONVERSATION_HEADER = "x-segmentstream-agent-conversation";
|
|
7
|
+
export const SESSION_HEADER = "x-segmentstream-agent-session";
|
|
8
|
+
const CONVERSATION = /^[a-z][a-z0-9_-]{0,31}:[A-Za-z0-9:_-]{1,200}$/;
|
|
9
|
+
const SESSION = /^[A-Za-z0-9:_.-]{1,200}$/;
|
|
10
|
+
/** Both temporary attachments and stored files require the runtime and its paired member. */
|
|
11
|
+
export function authenticateAgentCaller(input) {
|
|
12
|
+
return Effect.gen(function* () {
|
|
13
|
+
const { request, agents, verify } = input;
|
|
14
|
+
const requestId = yield* RequestId;
|
|
15
|
+
const credential = HttpApiBuilder.securityDecode(HttpApiSecurity.bearer);
|
|
16
|
+
const bearer = yield* Effect.provideService(credential, HttpServerRequest.HttpServerRequest, request);
|
|
17
|
+
const token = Redacted.value(bearer);
|
|
18
|
+
const verified = token === "" ? undefined : yield* Effect.promise(() => verify(token));
|
|
19
|
+
const denied = (response) => ({ ok: false, response: toServerResponse(response) });
|
|
20
|
+
switch (verified?.status) {
|
|
21
|
+
case "valid":
|
|
22
|
+
break;
|
|
23
|
+
case "invalid":
|
|
24
|
+
yield* logRuntimeRejection(verified.reason);
|
|
25
|
+
return denied(unauthorized(requestId, "The bearer token is invalid, expired or revoked."));
|
|
26
|
+
case "unavailable":
|
|
27
|
+
return denied(unavailable(requestId));
|
|
28
|
+
default:
|
|
29
|
+
return denied(forbidden(requestId, "Only the app's agent calls this route."));
|
|
30
|
+
}
|
|
31
|
+
const actAs = request.headers[ACT_AS_HEADER]?.trim() ?? "";
|
|
32
|
+
const conversation = request.headers[CONVERSATION_HEADER]?.trim() ?? "";
|
|
33
|
+
const session = request.headers[SESSION_HEADER]?.trim() ?? "";
|
|
34
|
+
const member = actAs === "" ? undefined : yield* agents.actingMember(actAs);
|
|
35
|
+
if (member === undefined) {
|
|
36
|
+
return denied(forbidden(requestId, "The member is inactive or has not paired with the agent."));
|
|
37
|
+
}
|
|
38
|
+
if (!CONVERSATION.test(conversation) || (session !== "" && !SESSION.test(session))) {
|
|
39
|
+
return denied(forbidden(requestId, "The agent names the chat and session of every request."));
|
|
40
|
+
}
|
|
41
|
+
const caller = {
|
|
42
|
+
principal: member,
|
|
43
|
+
conversation,
|
|
44
|
+
...(session === "" ? {} : { sessionId: session }),
|
|
45
|
+
};
|
|
46
|
+
return { ok: true, caller };
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { type SessionExchange, type TokenVerifier } from "../authentication.js";
|
|
4
|
+
import { RequestId } from "../errors.js";
|
|
5
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
6
|
+
import { type RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
7
|
+
import { type ConversationTransport } from "./conversations.js";
|
|
8
|
+
/** Same member scope as MCP, accessible to app clients with their usual bearer/session. */
|
|
9
|
+
export declare function conversationRoutes(options: {
|
|
10
|
+
readonly transport: ConversationTransport;
|
|
11
|
+
readonly verifyToken: TokenVerifier;
|
|
12
|
+
readonly verifyRuntime?: RuntimeTokenVerifier | undefined;
|
|
13
|
+
readonly exchangeSession?: SessionExchange | undefined;
|
|
14
|
+
}): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Effect, Layer, Schema } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { HttpApiBuilder, HttpApiSecurity } from "effect/unstable/httpapi";
|
|
4
|
+
import { CurrentPrincipal, withPrincipal, } from "../authentication.js";
|
|
5
|
+
import { RequestId, expectedFailure, errorBodyForWire } from "../errors.js";
|
|
6
|
+
import { identityToken } from "../operations.js";
|
|
7
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
8
|
+
import { runtimeCredential } from "../runtime-identity.js";
|
|
9
|
+
import { ConversationSearch } from "./conversations.js";
|
|
10
|
+
/** Same member scope as MCP, accessible to app clients with their usual bearer/session. */
|
|
11
|
+
export function conversationRoutes(options) {
|
|
12
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
13
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
14
|
+
const backend = yield* OntologyBackend;
|
|
15
|
+
yield* router.add("POST", "/api/ontology/agent/conversations/search", (request) => Effect.gen(function* () {
|
|
16
|
+
const bearer = yield* HttpApiBuilder.securityDecode(HttpApiSecurity.bearer).pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, request));
|
|
17
|
+
const operation = Effect.gen(function* () {
|
|
18
|
+
const search = yield* Schema.decodeUnknownEffect(ConversationSearch)(yield* request.json);
|
|
19
|
+
const principal = yield* CurrentPrincipal;
|
|
20
|
+
const page = yield* options.transport({
|
|
21
|
+
search,
|
|
22
|
+
principal,
|
|
23
|
+
identityToken: identityToken(request),
|
|
24
|
+
});
|
|
25
|
+
return HttpServerResponse.jsonUnsafe(page, {
|
|
26
|
+
headers: { "cache-control": "private, no-store" },
|
|
27
|
+
});
|
|
28
|
+
}).pipe(Effect.catch((error) => Effect.gen(function* () {
|
|
29
|
+
const requestId = yield* RequestId;
|
|
30
|
+
const failure = expectedFailure(error, requestId);
|
|
31
|
+
return HttpServerResponse.jsonUnsafe(errorBodyForWire(failure?.body ?? {
|
|
32
|
+
error: "invalid_request",
|
|
33
|
+
detail: "The conversation request is invalid.",
|
|
34
|
+
requestId,
|
|
35
|
+
}), { status: failure?.status ?? 400 });
|
|
36
|
+
})));
|
|
37
|
+
return yield* withPrincipal(bearer, options.verifyToken, backend, operation, {
|
|
38
|
+
runtime: runtimeCredential(options.verifyRuntime, request),
|
|
39
|
+
session: { request, exchange: options.exchangeSession },
|
|
40
|
+
});
|
|
41
|
+
}));
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import type { Principal } from "../../runtime.js";
|
|
3
|
+
import { PermissionDenied } from "../../errors.js";
|
|
4
|
+
import type { OntologyEnvironment } from "../config.js";
|
|
5
|
+
export declare const ConversationSearch: Schema.Struct<{
|
|
6
|
+
readonly q: Schema.optionalKey<Schema.String>;
|
|
7
|
+
readonly provider: Schema.optionalKey<Schema.String>;
|
|
8
|
+
readonly chatId: Schema.optionalKey<Schema.String>;
|
|
9
|
+
readonly threadId: Schema.optionalKey<Schema.String>;
|
|
10
|
+
readonly senderId: Schema.optionalKey<Schema.String>;
|
|
11
|
+
readonly from: Schema.optionalKey<Schema.String>;
|
|
12
|
+
readonly to: Schema.optionalKey<Schema.String>;
|
|
13
|
+
readonly before: Schema.optionalKey<Schema.String>;
|
|
14
|
+
readonly limit: Schema.optionalKey<Schema.Int>;
|
|
15
|
+
}>;
|
|
16
|
+
export type ConversationSearch = typeof ConversationSearch.Type;
|
|
17
|
+
export declare const ConversationPage: Schema.Struct<{
|
|
18
|
+
readonly events: Schema.$Array<Schema.$Record<Schema.String, Schema.Unknown>>;
|
|
19
|
+
readonly nextCursor: Schema.NullOr<Schema.String>;
|
|
20
|
+
}>;
|
|
21
|
+
export type ConversationPage = typeof ConversationPage.Type;
|
|
22
|
+
declare const ConversationArchiveUnavailable_base: Schema.Class<ConversationArchiveUnavailable, Schema.TaggedStruct<"ConversationArchiveUnavailable", {
|
|
23
|
+
readonly diagnostic: Schema.String;
|
|
24
|
+
readonly cause: Schema.optionalKey<Schema.Unknown>;
|
|
25
|
+
}>, import("effect/Cause").YieldableError>;
|
|
26
|
+
export declare class ConversationArchiveUnavailable extends ConversationArchiveUnavailable_base {
|
|
27
|
+
}
|
|
28
|
+
export type ConversationTransport = (input: {
|
|
29
|
+
readonly search: ConversationSearch;
|
|
30
|
+
readonly principal: Principal;
|
|
31
|
+
readonly identityToken: string | undefined;
|
|
32
|
+
}) => Effect.Effect<ConversationPage, ConversationArchiveUnavailable | PermissionDenied>;
|
|
33
|
+
/** The authenticated ontology service forwards its resolved member, never a caller-supplied ID. */
|
|
34
|
+
export declare function platformConversations(environment: OntologyEnvironment, fetchImpl?: typeof fetch): ConversationTransport;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { PermissionDenied } from "../../errors.js";
|
|
3
|
+
import { isAllowed } from "../../permissions.js";
|
|
4
|
+
import { describeError } from "../aggregate-platform.js";
|
|
5
|
+
const Filter = Schema.String.check(Schema.isMaxLength(1000));
|
|
6
|
+
export const ConversationSearch = Schema.Struct({
|
|
7
|
+
q: Schema.optionalKey(Filter),
|
|
8
|
+
provider: Schema.optionalKey(Filter),
|
|
9
|
+
chatId: Schema.optionalKey(Filter),
|
|
10
|
+
threadId: Schema.optionalKey(Filter),
|
|
11
|
+
senderId: Schema.optionalKey(Filter),
|
|
12
|
+
from: Schema.optionalKey(Filter),
|
|
13
|
+
to: Schema.optionalKey(Filter),
|
|
14
|
+
before: Schema.optionalKey(Filter),
|
|
15
|
+
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 200 }))),
|
|
16
|
+
});
|
|
17
|
+
export const ConversationPage = Schema.Struct({
|
|
18
|
+
events: Schema.Array(Schema.Record(Schema.String, Schema.Unknown)),
|
|
19
|
+
nextCursor: Schema.NullOr(Schema.String),
|
|
20
|
+
});
|
|
21
|
+
export class ConversationArchiveUnavailable extends Schema.TaggedError()("ConversationArchiveUnavailable", { diagnostic: Schema.String, cause: Schema.optionalKey(Schema.Unknown) }) {
|
|
22
|
+
}
|
|
23
|
+
/** The authenticated ontology service forwards its resolved member, never a caller-supplied ID. */
|
|
24
|
+
export function platformConversations(environment, fetchImpl = fetch) {
|
|
25
|
+
return ({ search, principal, identityToken }) => {
|
|
26
|
+
if (principal.kind !== "member") {
|
|
27
|
+
return Effect.fail(new PermissionDenied({
|
|
28
|
+
message: "Only members may read conversations.",
|
|
29
|
+
permission: "conversation:participant",
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
if (!identityToken) {
|
|
33
|
+
return Effect.fail(new ConversationArchiveUnavailable({ diagnostic: "runtime_identity_missing" }));
|
|
34
|
+
}
|
|
35
|
+
return Effect.tryPromise({
|
|
36
|
+
try: async (signal) => {
|
|
37
|
+
const base = `${environment.segmentstreamUrl}/api/internal/v1/workspaces/`;
|
|
38
|
+
const response = await fetchImpl(`${base}${encodeURIComponent(environment.workspaceId)}/agent/conversations/search`, {
|
|
39
|
+
method: "POST",
|
|
40
|
+
redirect: "error",
|
|
41
|
+
headers: {
|
|
42
|
+
"content-type": "application/json",
|
|
43
|
+
"x-segmentstream-organization-id": environment.organizationId,
|
|
44
|
+
"x-segmentstream-runtime-identity": identityToken,
|
|
45
|
+
},
|
|
46
|
+
body: JSON.stringify({
|
|
47
|
+
search,
|
|
48
|
+
reader: {
|
|
49
|
+
participantId: principal.id,
|
|
50
|
+
groups: isAllowed("agent_chat:read", principal),
|
|
51
|
+
admin: false,
|
|
52
|
+
},
|
|
53
|
+
}),
|
|
54
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(15_000)]),
|
|
55
|
+
});
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
throw new Error(`Conversation archive returned HTTP ${response.status}; ` +
|
|
58
|
+
`request ${response.headers.get("x-request-id") ?? "unknown"}.`);
|
|
59
|
+
}
|
|
60
|
+
return Schema.decodeUnknownSync(ConversationPage)(await response.json());
|
|
61
|
+
},
|
|
62
|
+
catch: (cause) => new ConversationArchiveUnavailable({ diagnostic: "archive_request_failed", cause }),
|
|
63
|
+
}).pipe(Effect.tapError((error) => Effect.logWarning("ontology.conversations.read_failed").pipe(Effect.annotateLogs({
|
|
64
|
+
diagnostic: error.diagnostic,
|
|
65
|
+
error: describeError(error.cause, (text) => text.split(identityToken).join("[redacted]")),
|
|
66
|
+
}))));
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Layer } from "effect";
|
|
2
|
+
import { HttpRouter } from "effect/unstable/http";
|
|
3
|
+
import { RequestId } from "../errors.js";
|
|
4
|
+
import type { ApiPlan } from "../plan.js";
|
|
5
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
6
|
+
import type { RuntimeTokenVerifier } from "../runtime-identity.js";
|
|
7
|
+
export declare const FILES_PATH = "/api/ontology/agent/files";
|
|
8
|
+
/** A short-lived download grant, issued only after the runtime's member may read the file. */
|
|
9
|
+
export declare function filesLayer(options: {
|
|
10
|
+
readonly plan: ApiPlan;
|
|
11
|
+
readonly verifyRuntime: RuntimeTokenVerifier | undefined;
|
|
12
|
+
}): Layer.Layer<never, never, OntologyBackend | HttpRouter.HttpRouter | HttpRouter.Request<"Requires", RequestId>>;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Cause, Effect, Layer } from "effect";
|
|
2
|
+
import { HttpRouter, HttpServerResponse } from "effect/unstable/http";
|
|
3
|
+
import { resolveFileRecord } from "../../runtime/files.js";
|
|
4
|
+
import { isUuid } from "../../query.js";
|
|
5
|
+
import { RequestId, failureResponse, notFound, toServerResponse } from "../errors.js";
|
|
6
|
+
import { OntologyBackend } from "../runtime-contract.js";
|
|
7
|
+
import { authenticateAgentCaller } from "./authentication.js";
|
|
8
|
+
import { AGENT_PATH } from "./server.js";
|
|
9
|
+
export const FILES_PATH = `${AGENT_PATH}/files`;
|
|
10
|
+
/** A short-lived download grant, issued only after the runtime's member may read the file. */
|
|
11
|
+
export function filesLayer(options) {
|
|
12
|
+
return Layer.effectDiscard(Effect.gen(function* () {
|
|
13
|
+
const router = yield* HttpRouter.HttpRouter;
|
|
14
|
+
const backend = yield* OntologyBackend;
|
|
15
|
+
const { blobs, agents } = backend;
|
|
16
|
+
const verify = options.verifyRuntime;
|
|
17
|
+
if (blobs === undefined || agents === undefined || verify === undefined) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const read = (request) => Effect.gen(function* () {
|
|
21
|
+
const requestId = yield* RequestId;
|
|
22
|
+
const authenticated = yield* authenticateAgentCaller({ request, agents, verify });
|
|
23
|
+
if (!authenticated.ok) {
|
|
24
|
+
return authenticated.response;
|
|
25
|
+
}
|
|
26
|
+
const params = yield* HttpRouter.params;
|
|
27
|
+
const id = params.id ?? "";
|
|
28
|
+
if (!isUuid(id)) {
|
|
29
|
+
return toServerResponse(notFound(requestId, "File record not found."));
|
|
30
|
+
}
|
|
31
|
+
const resolved = yield* resolveFileRecord({
|
|
32
|
+
manifest: options.plan.ontology.manifest,
|
|
33
|
+
reads: backend,
|
|
34
|
+
principal: authenticated.caller.principal,
|
|
35
|
+
reference: {
|
|
36
|
+
entity: params.entity ?? "",
|
|
37
|
+
id,
|
|
38
|
+
...(params.property === undefined ? {} : { property: params.property }),
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
// Pin the revision read above. download rechecks live permissions in its transaction.
|
|
42
|
+
const download = yield* blobs.download(resolved.entity, resolved.id, resolved.property, { revisionId: resolved.file.id }, authenticated.caller.principal);
|
|
43
|
+
return HttpServerResponse.jsonUnsafe({
|
|
44
|
+
file: {
|
|
45
|
+
id,
|
|
46
|
+
entity: resolved.entity,
|
|
47
|
+
property: resolved.property,
|
|
48
|
+
filename: resolved.file.filename,
|
|
49
|
+
content_type: resolved.file.contentType,
|
|
50
|
+
size: resolved.file.size,
|
|
51
|
+
},
|
|
52
|
+
download: {
|
|
53
|
+
url: download.url,
|
|
54
|
+
method: download.method,
|
|
55
|
+
headers: download.headers,
|
|
56
|
+
expires_at: download.expiresAt,
|
|
57
|
+
},
|
|
58
|
+
}, { headers: { "cache-control": "no-store" } });
|
|
59
|
+
}).pipe(Effect.catchTag("UnsupportedFile", (error) => Effect.map(RequestId, (requestId) => HttpServerResponse.jsonUnsafe({
|
|
60
|
+
error: "unsupported",
|
|
61
|
+
detail: error.message,
|
|
62
|
+
request_id: requestId,
|
|
63
|
+
}, { status: 422 }))), Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause)
|
|
64
|
+
? Effect.failCause(cause)
|
|
65
|
+
: Effect.flatMap(RequestId, (requestId) => failureResponse(cause, requestId))));
|
|
66
|
+
yield* router.add("GET", `${FILES_PATH}/:entity/:id`, read);
|
|
67
|
+
yield* router.add("GET", `${FILES_PATH}/:entity/:id/:property`, read);
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -170,7 +170,13 @@ function seeChat(store) {
|
|
|
170
170
|
if (Result.isFailure(decoded)) {
|
|
171
171
|
return invalidBody(requestId, schemaIssues(decoded.failure));
|
|
172
172
|
}
|
|
173
|
-
|
|
173
|
+
const chat = yield* store.seeChat(decoded.success);
|
|
174
|
+
yield* Effect.logInfo("ontology.agent.chat_reported").pipe(Effect.annotateLogs({
|
|
175
|
+
requestId,
|
|
176
|
+
provider: decoded.success.provider,
|
|
177
|
+
chatId: decoded.success.externalId,
|
|
178
|
+
}));
|
|
179
|
+
return json(chat);
|
|
174
180
|
});
|
|
175
181
|
}
|
|
176
182
|
/** `GET /api/ontology/agent/identities` (member): the member's own live identities. */
|
package/dist/service/errors.js
CHANGED
|
@@ -385,6 +385,17 @@ export function expectedFailure(error, requestId) {
|
|
|
385
385
|
},
|
|
386
386
|
};
|
|
387
387
|
}
|
|
388
|
+
if (Predicate.isTagged(error, "ConversationArchiveUnavailable")) {
|
|
389
|
+
return {
|
|
390
|
+
status: 502,
|
|
391
|
+
body: {
|
|
392
|
+
error: "conversation_archive_unavailable",
|
|
393
|
+
detail: "The conversation archive is unavailable. Try again shortly.",
|
|
394
|
+
requestId,
|
|
395
|
+
retryable: true,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
388
399
|
if (Predicate.isTagged(error, "AggregateUpstreamUnavailable")) {
|
|
389
400
|
const { reason } = error;
|
|
390
401
|
return {
|
|
@@ -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 ConversationTransport } from "../agent/conversations.js";
|
|
7
8
|
import type { AgentChannelFactory } from "../agent/channel.js";
|
|
8
9
|
import type { ConnectionTransportFactory } from "../connections.js";
|
|
9
10
|
import { RequestId } from "../errors.js";
|
|
@@ -26,6 +27,7 @@ export interface McpServerOptions {
|
|
|
26
27
|
readonly aggregates: Aggregates;
|
|
27
28
|
/** The workspace's agent, for `agent_chat.send_message`; `undefined` without one. */
|
|
28
29
|
readonly agentChannel?: AgentChannelFactory | undefined;
|
|
30
|
+
readonly conversations?: ConversationTransport | undefined;
|
|
29
31
|
}
|
|
30
32
|
/**
|
|
31
33
|
* The ontology's MCP server at `POST /mcp`: Streamable HTTP without sessions, so any instance can
|
|
@@ -6,6 +6,7 @@ import { isAllowed, permissionOf, READ_VERB } from "../../permissions.js";
|
|
|
6
6
|
import { SCOPE_MISMATCH } from "../aggregate-platform.js";
|
|
7
7
|
import { CurrentPrincipal, withPrincipal } from "../authentication.js";
|
|
8
8
|
import { catalogFor } from "../catalog.js";
|
|
9
|
+
import { ConversationSearch } from "../agent/conversations.js";
|
|
9
10
|
import { describeCatalog, measureDescription, measuresFor, RECORDS_OR_AGGREGATES, } from "../../render.js";
|
|
10
11
|
import { RequestId, errorBodyForWire, expectedFailure, internalError, schemaIssues, } from "../errors.js";
|
|
11
12
|
import { publicOrigin } from "../oauth/protocol.js";
|
|
@@ -89,7 +90,11 @@ function encoded(schema, value) {
|
|
|
89
90
|
return Schema.encodeUnknownEffect(schema)(value).pipe(Effect.orDie);
|
|
90
91
|
}
|
|
91
92
|
const GetArguments = Schema.Struct({ id: IdParam });
|
|
92
|
-
const HistoryArguments = Schema.Struct({
|
|
93
|
+
const HistoryArguments = Schema.Struct({
|
|
94
|
+
id: IdParam,
|
|
95
|
+
...HistoryParams,
|
|
96
|
+
conversation_before: Schema.optionalKey(Schema.String),
|
|
97
|
+
});
|
|
93
98
|
function structured(value) {
|
|
94
99
|
return { text: JSON.stringify(value), structured: value };
|
|
95
100
|
}
|
|
@@ -163,7 +168,7 @@ function toolFailure(cause, requestId) {
|
|
|
163
168
|
isError: true,
|
|
164
169
|
}));
|
|
165
170
|
}
|
|
166
|
-
function runTool({ built, operations, aggregate }, tool, args, request) {
|
|
171
|
+
function runTool({ built, operations, aggregate, conversations }, tool, args, request) {
|
|
167
172
|
return Effect.gen(function* () {
|
|
168
173
|
const { target } = tool;
|
|
169
174
|
switch (target.kind) {
|
|
@@ -191,9 +196,30 @@ function runTool({ built, operations, aggregate }, tool, args, request) {
|
|
|
191
196
|
const { id } = yield* decodeArguments(GetArguments, args);
|
|
192
197
|
return structured(yield* encoded(schemas.record, yield* operations.get(target.planned, id)));
|
|
193
198
|
}
|
|
199
|
+
case "conversation-search": {
|
|
200
|
+
const search = yield* decodeArguments(ConversationSearch, args);
|
|
201
|
+
const principal = yield* CurrentPrincipal;
|
|
202
|
+
return structured(yield* conversations({ search, principal, identityToken: identityToken(request) }));
|
|
203
|
+
}
|
|
194
204
|
case "history": {
|
|
195
|
-
const { id, ...params } = yield* decodeArguments(HistoryArguments, args);
|
|
196
|
-
|
|
205
|
+
const { id, conversation_before, ...params } = yield* decodeArguments(HistoryArguments, args);
|
|
206
|
+
const audit = yield* encoded(HistoryPage, yield* operations.history(target.planned, id, params));
|
|
207
|
+
if (target.planned.name !== "agent_chat" || conversations === undefined) {
|
|
208
|
+
return structured(audit);
|
|
209
|
+
}
|
|
210
|
+
const chat = yield* operations.get(target.planned, id);
|
|
211
|
+
const principal = yield* CurrentPrincipal;
|
|
212
|
+
const conversation = yield* conversations({
|
|
213
|
+
principal,
|
|
214
|
+
identityToken: identityToken(request),
|
|
215
|
+
search: {
|
|
216
|
+
provider: String(chat.provider),
|
|
217
|
+
chatId: String(chat.external_id),
|
|
218
|
+
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
219
|
+
...(conversation_before === undefined ? {} : { before: conversation_before }),
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
return structured({ ...audit, conversation });
|
|
197
223
|
}
|
|
198
224
|
case "action": {
|
|
199
225
|
const { [ACTION_OPTIONS_ARGUMENT]: rawOptions, ...input } = args;
|
|
@@ -433,7 +459,7 @@ function handlePost(server, verifyToken, verifyRuntime, backend) {
|
|
|
433
459
|
* authentication. `GET` and `DELETE`, which open a server stream and end a session, answer 405.
|
|
434
460
|
*/
|
|
435
461
|
export function mcpLayer(options) {
|
|
436
|
-
const tools = mcpTools(options.built);
|
|
462
|
+
const tools = mcpTools(options.built, options.conversations !== undefined);
|
|
437
463
|
return Layer.effectDiscard(Effect.gen(function* () {
|
|
438
464
|
const router = yield* HttpRouter.HttpRouter;
|
|
439
465
|
const backend = yield* OntologyBackend;
|
|
@@ -442,6 +468,7 @@ export function mcpLayer(options) {
|
|
|
442
468
|
tools,
|
|
443
469
|
operations: makeOperations(backend, options.built.plan, options.hooks, options.connections, options.replicate, options.agentChannel),
|
|
444
470
|
aggregate: options.aggregates.model,
|
|
471
|
+
conversations: options.conversations,
|
|
445
472
|
};
|
|
446
473
|
yield* router.add("POST", MCP_PATH, handlePost(server, options.verifyToken, options.verifyRuntime, backend));
|
|
447
474
|
yield* router.add("GET", MCP_PATH, METHOD_NOT_ALLOWED);
|
|
@@ -26,6 +26,8 @@ export type McpToolTarget = {
|
|
|
26
26
|
readonly kind: "me";
|
|
27
27
|
} | {
|
|
28
28
|
readonly kind: "aggregate";
|
|
29
|
+
} | {
|
|
30
|
+
readonly kind: "conversation-search";
|
|
29
31
|
};
|
|
30
32
|
/** A tool of the ontology's MCP server, derived from the same plan as the HTTP routes. */
|
|
31
33
|
export interface McpTool {
|
|
@@ -58,7 +60,7 @@ export declare const ACTION_OPTIONS_ARGUMENT = "_options";
|
|
|
58
60
|
* as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
|
|
59
61
|
* `check` already refuses.
|
|
60
62
|
*/
|
|
61
|
-
export declare function mcpTools(built: BuiltApi): ReadonlyArray<McpTool>;
|
|
63
|
+
export declare function mcpTools(built: BuiltApi, conversations?: boolean): ReadonlyArray<McpTool>;
|
|
62
64
|
/**
|
|
63
65
|
* The tools a caller may see: those whose every permission the caller's roles grant, each with the
|
|
64
66
|
* caller's input schema.
|
|
@@ -409,7 +409,7 @@ function aggregateTool(manifest) {
|
|
|
409
409
|
* as they have no record routes. Throws {@link InvalidOntology} when two tools share a name, which
|
|
410
410
|
* `check` already refuses.
|
|
411
411
|
*/
|
|
412
|
-
export function mcpTools(built) {
|
|
412
|
+
export function mcpTools(built, conversations = false) {
|
|
413
413
|
const { manifest } = built.plan.ontology;
|
|
414
414
|
const tools = [
|
|
415
415
|
...GLOBAL_TOOLS,
|
|
@@ -423,6 +423,50 @@ export function mcpTools(built) {
|
|
|
423
423
|
tools.push(...readTools(planned, schemas, built));
|
|
424
424
|
tools.push(...planned.actions.map((action) => actionTool(planned, action)));
|
|
425
425
|
}
|
|
426
|
+
if (conversations) {
|
|
427
|
+
const index = tools.findIndex((tool) => tool.name === "agent_chat_history");
|
|
428
|
+
if (index >= 0) {
|
|
429
|
+
const tool = tools[index];
|
|
430
|
+
const rest = { ...tool };
|
|
431
|
+
delete rest.outputSchema;
|
|
432
|
+
tools[index] = {
|
|
433
|
+
...rest,
|
|
434
|
+
description: tool.description +
|
|
435
|
+
" Also returns conversation.events: message texts, reply/silent decisions and reasons, " +
|
|
436
|
+
"tool calls/results and attachment references. Conversation pagination uses conversation_before " +
|
|
437
|
+
"separately from the audit after cursor.",
|
|
438
|
+
inputSchema: {
|
|
439
|
+
...tool.inputSchema,
|
|
440
|
+
properties: {
|
|
441
|
+
...tool.inputSchema.properties,
|
|
442
|
+
conversation_before: {
|
|
443
|
+
type: "string",
|
|
444
|
+
description: "conversation.nextCursor from the previous page.",
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
tools.push({
|
|
451
|
+
name: "agent_chat_search",
|
|
452
|
+
title: "Search stored conversations",
|
|
453
|
+
description: "Search retained conversation messages, decisions and tool calls by text, provider chat ID, " +
|
|
454
|
+
"sender ID, topic and date range. Returns only groups you may read (agent_chat:read) and DMs " +
|
|
455
|
+
"in which you participated. Dates are ISO 8601; before is the nextCursor of the previous page. " +
|
|
456
|
+
"Workspace administrators can read all DMs through the platform API.",
|
|
457
|
+
inputSchema: {
|
|
458
|
+
type: "object",
|
|
459
|
+
additionalProperties: false,
|
|
460
|
+
properties: {
|
|
461
|
+
...Object.fromEntries(["q", "provider", "chatId", "threadId", "senderId", "from", "to", "before"].map((key) => [key, { type: "string", maxLength: 1000 }])),
|
|
462
|
+
limit: { type: "integer", minimum: 1, maximum: 200 },
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
annotations: READ,
|
|
466
|
+
permissions: [],
|
|
467
|
+
target: { kind: "conversation-search" },
|
|
468
|
+
});
|
|
469
|
+
}
|
|
426
470
|
const seen = new Set();
|
|
427
471
|
const duplicates = tools.filter(({ name }) => seen.size === seen.add(name).size);
|
|
428
472
|
if (duplicates.length > 0) {
|
package/dist/service/service.js
CHANGED
|
@@ -5,6 +5,9 @@ import { storageMaintenanceAuthenticationLayer } from "../storage/maintenance-au
|
|
|
5
5
|
import { nameToWire } from "../authoring-names.js";
|
|
6
6
|
import { sqlServiceRuntime } from "../runtime/service-binding.js";
|
|
7
7
|
import { attachmentsLayer } from "./agent/attachments.js";
|
|
8
|
+
import { filesLayer } from "./agent/files.js";
|
|
9
|
+
import { platformConversations } from "./agent/conversations.js";
|
|
10
|
+
import { conversationRoutes } from "./agent/conversation-routes.js";
|
|
8
11
|
import { deployedAgentChannel } from "./agent/channel.js";
|
|
9
12
|
import { agentLayer } from "./agent/server.js";
|
|
10
13
|
import { makeAggregates } from "./aggregate.js";
|
|
@@ -95,6 +98,7 @@ export function createOntologyService(ontology, config) {
|
|
|
95
98
|
const agentChannel = config.runtimeIdentity === undefined || config.agentUrl === undefined
|
|
96
99
|
? undefined
|
|
97
100
|
: deployedAgentChannel(config.agentUrl);
|
|
101
|
+
const conversations = config.runtimeIdentity === undefined ? undefined : platformConversations(config);
|
|
98
102
|
const aggregates = makeAggregates({
|
|
99
103
|
manifest: ontology.manifest,
|
|
100
104
|
transport: platformAggregates({ ...config, manifestHash: ontology.manifest.hash }),
|
|
@@ -115,6 +119,7 @@ export function createOntologyService(ontology, config) {
|
|
|
115
119
|
replicate,
|
|
116
120
|
aggregates,
|
|
117
121
|
agentChannel,
|
|
122
|
+
conversations,
|
|
118
123
|
}), oauthLayer({ verifyToken }));
|
|
119
124
|
const agent = agentLayer({ verifyToken, verifyRuntime, exchangeSession });
|
|
120
125
|
const attachments = attachmentsLayer({
|
|
@@ -126,7 +131,14 @@ export function createOntologyService(ontology, config) {
|
|
|
126
131
|
verifyToken,
|
|
127
132
|
verifyRuntime,
|
|
128
133
|
});
|
|
129
|
-
const application = Layer.mergeAll(routes, mcp, agent, attachments,
|
|
134
|
+
const application = Layer.mergeAll(routes, mcp, agent, attachments, filesLayer({ plan: built.plan, verifyRuntime }), conversations === undefined
|
|
135
|
+
? Layer.empty
|
|
136
|
+
: conversationRoutes({
|
|
137
|
+
transport: conversations,
|
|
138
|
+
verifyToken,
|
|
139
|
+
verifyRuntime,
|
|
140
|
+
exchangeSession,
|
|
141
|
+
}), DocsRoute, UnmatchedRoutes).pipe(Layer.provide(backend), Layer.provide(HttpServer.layerServices));
|
|
130
142
|
const web = HttpRouter.toWebHandler(application, { disableLogger: true });
|
|
131
143
|
const hash = ontology.manifest.hash;
|
|
132
144
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@segmentstream-cloud/ontology",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.0",
|
|
4
4
|
"description": "Define a SegmentStream workspace app's ontology: entities, relationships, roles and actions, written with Effect.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "SegmentStream",
|