@joe-broadhead/messaging 0.7.1 → 0.9.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/CHANGELOG.md +364 -2
- package/dist/bin/messaging-mcp.js +5498 -4338
- package/dist/bin/messaging-relay.js +8626 -5540
- package/dist/bin/messaging.js +4492 -3158
- package/dist/bin/messagingd.js +19672 -12412
- package/dist/lib/modules/core/agent/operations.js +19 -327
- package/dist/lib/modules/core/agent/present.js +20 -55
- package/dist/lib/modules/core/agent-tools.js +35 -139
- package/dist/lib/modules/core/consent.js +54 -14
- package/dist/lib/modules/core/contact-card.js +3 -3
- package/dist/lib/modules/core/conversation/event.js +29 -4
- package/dist/lib/modules/core/conversation/guest-invitation.js +42 -0
- package/dist/lib/modules/core/conversation/log.js +287 -65
- package/dist/lib/modules/core/conversation/request-log.js +158 -0
- package/dist/lib/modules/core/conversation/request-seal.js +41 -0
- package/dist/lib/modules/core/conversation/requests.js +87 -0
- package/dist/lib/modules/core/conversation/seal.js +90 -37
- package/dist/lib/modules/core/conversation-agent-tools.js +69 -0
- package/dist/lib/modules/core/federation/directory-proof.js +83 -0
- package/dist/lib/modules/core/federation/proof-state.js +167 -0
- package/dist/lib/modules/core/federation/roster-proof.js +74 -0
- package/dist/lib/modules/core/index.js +9 -4
- package/dist/lib/modules/core/jelly-host.js +8 -5
- package/dist/lib/modules/core/schema/common.js +33 -5
- package/dist/lib/modules/core/schema/consent.js +23 -5
- package/dist/lib/modules/core/schema/contact-request.js +22 -0
- package/dist/lib/modules/core/schema/conv-frames.js +101 -0
- package/dist/lib/modules/core/schema/conversation.js +67 -33
- package/dist/lib/modules/core/schema/directory-frames.js +8 -3
- package/dist/lib/modules/core/schema/envelope.js +8 -24
- package/dist/lib/modules/core/schema/errors.js +7 -5
- package/dist/lib/modules/core/schema/guest-invitation.js +13 -0
- package/dist/lib/modules/core/schema/index.js +5 -0
- package/dist/lib/modules/core/schema/ipc/consent.js +97 -18
- package/dist/lib/modules/core/schema/ipc/conversation-views.js +87 -0
- package/dist/lib/modules/core/schema/ipc/conversations.js +132 -0
- package/dist/lib/modules/core/schema/ipc/device.js +9 -0
- package/dist/lib/modules/core/schema/ipc/jelly.js +17 -45
- package/dist/lib/modules/core/schema/ipc/shapes.js +74 -73
- package/dist/lib/modules/core/schema/ipc/surfaces.js +12 -195
- package/dist/lib/modules/core/schema/ipc/tasks.js +31 -0
- package/dist/lib/modules/core/schema/ipc.js +68 -46
- package/dist/lib/modules/core/testing.js +1 -1
- package/dist/lib/modules/core/version.js +1 -1
- package/dist/lib/modules/ipc/client.js +10 -2
- package/dist/lib/modules/ipc/server.js +14 -5
- package/dist/lib/modules/ipc/testing.js +13 -23
- package/dist/spec/messaging-v1.md +854 -445
- package/dist/third-party-notices.md +1 -1
- package/dist/third-party.json +1 -1
- package/dist/types/core/agent/operations.d.ts +5 -52
- package/dist/types/core/agent/present.d.ts +6 -21
- package/dist/types/core/agent-tools.d.ts +295 -442
- package/dist/types/core/consent.d.ts +30 -7
- package/dist/types/core/contact-card.d.ts +3 -3
- package/dist/types/core/conversation/event.d.ts +6 -6
- package/dist/types/core/conversation/guest-invitation.d.ts +8 -0
- package/dist/types/core/conversation/log.d.ts +28 -13
- package/dist/types/core/conversation/request-log.d.ts +24 -0
- package/dist/types/core/conversation/request-seal.d.ts +6 -0
- package/dist/types/core/conversation/requests.d.ts +14 -0
- package/dist/types/core/conversation/seal.d.ts +32 -9
- package/dist/types/core/conversation-agent-tools.d.ts +939 -0
- package/dist/types/core/federation/directory-proof.d.ts +8 -0
- package/dist/types/core/federation/proof-state.d.ts +114 -0
- package/dist/types/core/federation/roster-proof.d.ts +7 -0
- package/dist/types/core/index.d.ts +11 -6
- package/dist/types/core/jelly-host.d.ts +1 -1
- package/dist/types/core/schema/common.d.ts +33 -6
- package/dist/types/core/schema/consent.d.ts +29 -8
- package/dist/types/core/schema/contact-request.d.ts +264 -0
- package/dist/types/core/schema/conv-frames.d.ts +1204 -0
- package/dist/types/core/schema/conversation.d.ts +1410 -264
- package/dist/types/core/schema/directory-frames.d.ts +8 -3
- package/dist/types/core/schema/envelope.d.ts +85 -130
- package/dist/types/core/schema/errors.d.ts +5 -1
- package/dist/types/core/schema/guest-invitation.d.ts +51 -0
- package/dist/types/core/schema/index.d.ts +5 -0
- package/dist/types/core/schema/ipc/consent.d.ts +464 -62
- package/dist/types/core/schema/ipc/conversation-views.d.ts +1199 -0
- package/dist/types/core/schema/ipc/conversations.d.ts +4355 -0
- package/dist/types/core/schema/ipc/device.d.ts +10 -0
- package/dist/types/core/schema/ipc/jelly.d.ts +16 -111
- package/dist/types/core/schema/ipc/shapes.d.ts +211 -95
- package/dist/types/core/schema/ipc/surfaces.d.ts +26 -427
- package/dist/types/core/schema/ipc/tasks.d.ts +210 -0
- package/dist/types/core/schema/ipc.d.ts +3859 -890
- package/dist/types/core/schema/relay.d.ts +12 -4
- package/dist/types/core/testing.d.ts +1 -1
- package/dist/types/core/version.d.ts +1 -1
- package/dist/types/ipc/client.d.ts +4 -1
- package/dist/types/ipc/server.d.ts +16 -1
- package/dist/types/ipc/testing.d.ts +21 -8
- package/package.json +1 -1
|
@@ -1,34 +1,15 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { a2aTaskState,
|
|
3
|
-
import { LIMITS } from "../schema/common.js";
|
|
2
|
+
import { a2aTaskState, DONE_STATES, taskStateName, } from "../agent-tools.js";
|
|
4
3
|
import { MessagingError } from "../schema/errors.js";
|
|
5
4
|
import { canTransition, isTerminal } from "../task.js";
|
|
6
|
-
import { agentCan,
|
|
7
|
-
import { compact,
|
|
5
|
+
import { agentCan, isoOf, presentAgent, presentTaskEnvelope, presentTask, presentTaskItem, presentTaskView, } from "./present.js";
|
|
6
|
+
import { compact, validatedAddress } from "./text.js";
|
|
8
7
|
const DEFAULT_CONTACTS = 20;
|
|
9
|
-
const DEFAULT_INBOX = 20;
|
|
10
|
-
const DEFAULT_PAGE = 30;
|
|
11
8
|
const DEFAULT_TASKS = 20;
|
|
12
9
|
/** The daemon's largest `inbox.list` and `task.list` page. */
|
|
13
10
|
const PAGE = 100;
|
|
14
11
|
/** Pages a filtered task listing reads at most. */
|
|
15
12
|
const TASK_PAGES = 10;
|
|
16
|
-
const NOTE = {
|
|
17
|
-
line: "A line to a person: they see it in their chats, and it wakes no agent. messaging_read {with} shows when it is delivered.",
|
|
18
|
-
post: "Posted: everyone in the channel sees it, and it wakes no agent.",
|
|
19
|
-
sent: "Sent. messaging_read {id} shows how far it got.",
|
|
20
|
-
task: " Follow the task with messaging_read {id: taskId}.",
|
|
21
|
-
held: "Their person will see a request card first; you will be told when they answer. Follow it with messaging_read {id}.",
|
|
22
|
-
suggest: "Your person sees your line and your suggestion of that contact; only they can send a message request, and you are not told what they decide.",
|
|
23
|
-
};
|
|
24
|
-
/** `me`, or one's own person by name: the person's own conversation, `dm:<self>` (spec §6.8, design §5.9). */
|
|
25
|
-
function ownPerson(address, self) {
|
|
26
|
-
return address.kind === "me" || (address.kind === "person" && address.person === self);
|
|
27
|
-
}
|
|
28
|
-
/** `me` as the person it stands for, for the conversation checks and the daemon. */
|
|
29
|
-
function asPerson(address, self) {
|
|
30
|
-
return address.kind === "me" ? { kind: "person", person: self } : address;
|
|
31
|
-
}
|
|
32
13
|
export class Operations {
|
|
33
14
|
ctx;
|
|
34
15
|
constructor(ctx) {
|
|
@@ -40,15 +21,6 @@ export class Operations {
|
|
|
40
21
|
consent(person, s) {
|
|
41
22
|
return this.ctx.consent(person, s.person);
|
|
42
23
|
}
|
|
43
|
-
/** Refuses a chat conversation this client may not reach, before any daemon call. */
|
|
44
|
-
reachable(target, field) {
|
|
45
|
-
const { access } = this.ctx;
|
|
46
|
-
if (target.kind === "person" && !access.dms)
|
|
47
|
-
fail("forbidden", `${field}: conversations with persons are not available to this client`);
|
|
48
|
-
if (target.kind === "channel" && access.channels !== undefined && !access.channels.has(target.name)) {
|
|
49
|
-
fail("forbidden", `${field}: this client may reach only the channels its user allowed: ${[...access.channels].map((name) => `#${name}`).join(", ")}`);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
24
|
// ---- contacts ----
|
|
53
25
|
async contacts(sessionID, input, signal) {
|
|
54
26
|
const s = await this.ctx.session(sessionID, signal);
|
|
@@ -60,10 +32,11 @@ export class Operations {
|
|
|
60
32
|
const byPerson = new Map();
|
|
61
33
|
for (const entry of others)
|
|
62
34
|
byPerson.set(entry.person, [...(byPerson.get(entry.person) ?? []), entry]);
|
|
63
|
-
const unread = new Map(rooms.map((room) => [room.conversation, room.unread]));
|
|
64
35
|
for (const room of rooms)
|
|
65
|
-
if (room.kind === "dm")
|
|
66
|
-
|
|
36
|
+
if (room.kind === "dm" && room.dmWith) {
|
|
37
|
+
const person = room.dmWith.split("/")[0];
|
|
38
|
+
byPerson.set(person, byPerson.get(person) ?? []);
|
|
39
|
+
}
|
|
67
40
|
const name = (text) => this.framer.wrap(text, {}, 64);
|
|
68
41
|
// What each person's card says (spec §6.9, "Contact cards"), or the defaults; the daemon's, never a peer's text.
|
|
69
42
|
const cards = new Map(reach.map((r) => [r.person, r]));
|
|
@@ -106,25 +79,23 @@ export class Operations {
|
|
|
106
79
|
can: card
|
|
107
80
|
? { ...card, chat: card.chat && this.ctx.access.dms }
|
|
108
81
|
: { chat: !self && this.ctx.access.dms, agentMail: can.some((c) => c.agentMail), ask: can.some((c) => c.ask === "open") ? "open" : "no", source: "default" },
|
|
109
|
-
...(unread.get(`dm:${person}`) ? { unread: unread.get(`dm:${person}`) } : {}),
|
|
110
82
|
},
|
|
111
83
|
};
|
|
112
84
|
});
|
|
113
85
|
const allowed = this.ctx.access.channels;
|
|
114
86
|
const channels = rooms
|
|
115
|
-
.filter((room) => room.kind === "channel" && (allowed === undefined || allowed.has(room.
|
|
87
|
+
.filter((room) => room.kind === "channel" && (allowed === undefined || allowed.has(room.cid)))
|
|
116
88
|
.map((room) => {
|
|
117
|
-
const local = room.
|
|
89
|
+
const local = room.cid;
|
|
118
90
|
return {
|
|
119
91
|
match: [local, room.name ?? local],
|
|
120
92
|
contact: {
|
|
121
|
-
address:
|
|
93
|
+
address: local,
|
|
122
94
|
kind: "channel",
|
|
123
95
|
name: name(room.name ?? local),
|
|
124
96
|
org: "same",
|
|
125
97
|
presence: "unknown",
|
|
126
98
|
can: { chat: true, agentMail: false, ask: "no", source: "default" },
|
|
127
|
-
...(room.unread > 0 ? { unread: room.unread } : {}),
|
|
128
99
|
},
|
|
129
100
|
};
|
|
130
101
|
});
|
|
@@ -149,286 +120,37 @@ export class Operations {
|
|
|
149
120
|
...(contacts.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
150
121
|
};
|
|
151
122
|
}
|
|
152
|
-
// ---- send ----
|
|
153
|
-
/**
|
|
154
|
-
* Checks a send without side effects: the address, the peer-turn allowance, what may go to whom,
|
|
155
|
-
* what `replyTo` and `onBehalfOf` name. Returns what the send will do, for the permission prompt.
|
|
156
|
-
*/
|
|
157
|
-
async planSend(sessionID, input, signal) {
|
|
158
|
-
const s = await this.ctx.session(sessionID, signal);
|
|
159
|
-
const to = parseToolAddress(input.to, s.person, "to");
|
|
160
|
-
const own = ownPerson(to, s.person);
|
|
161
|
-
if (s.peer !== undefined && !own && !((to.kind === "person" || to.kind === "agent") && to.person === s.peer)) {
|
|
162
|
-
throw fail("guarded_turn", "to: this turn was started by a teammate's agent, so it can send only to that teammate or to your person");
|
|
163
|
-
}
|
|
164
|
-
const conversation = conversationOf(to, s.person);
|
|
165
|
-
// Spec §6.9, "Message requests": an agent never sends a request; it asks its own person to.
|
|
166
|
-
if (input.request !== undefined && !own)
|
|
167
|
-
throw fail("invalid_request", 'request: only with to: "me"; your person sends the message request');
|
|
168
|
-
if (to.kind !== "agent") {
|
|
169
|
-
const target = asPerson(to, s.person);
|
|
170
|
-
this.reachable(target, "to");
|
|
171
|
-
if (input.ask && own)
|
|
172
|
-
throw fail("invalid_request", "ask: a request card to your own person is not in this build; send them a line (without ask)");
|
|
173
|
-
if (input.ask)
|
|
174
|
-
throw fail("invalid_request", "ask: a task goes to an agent, <person>/<agent>");
|
|
175
|
-
if (input.files)
|
|
176
|
-
throw fail("invalid_request", "files: files go to an agent, never in a chat line");
|
|
177
|
-
if (input.onBehalfOf)
|
|
178
|
-
throw fail("invalid_request", "onBehalfOf: only with ask, to an agent");
|
|
179
|
-
if (input.text.length > LIMITS.chatChars)
|
|
180
|
-
throw fail("too_large", `text: a chat line is at most ${LIMITS.chatChars} characters`);
|
|
181
|
-
return {
|
|
182
|
-
kind: target.kind === "person" ? "line" : "post",
|
|
183
|
-
to,
|
|
184
|
-
conversation,
|
|
185
|
-
target: target.kind === "person" ? target.person : `#${target.name}`,
|
|
186
|
-
text: input.text,
|
|
187
|
-
...(input.replyTo !== undefined ? { thread: input.replyTo } : {}),
|
|
188
|
-
...(input.request !== undefined ? { request: input.request } : {}),
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
if (input.onBehalfOf !== undefined && !input.ask)
|
|
192
|
-
throw fail("invalid_request", "onBehalfOf: only with ask: true");
|
|
193
|
-
const reply = input.replyTo !== undefined ? await this.replyOf(s, to, input.replyTo, input.ask === true, signal) : {};
|
|
194
|
-
const parent = input.onBehalfOf !== undefined ? await this.delegationParent(s, input.onBehalfOf, signal) : undefined;
|
|
195
|
-
const parts = [{ text: input.text }, ...filesParts(input.files)];
|
|
196
|
-
return {
|
|
197
|
-
kind: input.ask ? "task" : "mail",
|
|
198
|
-
to,
|
|
199
|
-
conversation,
|
|
200
|
-
params: compact({
|
|
201
|
-
to: formatToolAddress(to),
|
|
202
|
-
kind: input.ask ? "task" : "message",
|
|
203
|
-
parts,
|
|
204
|
-
...reply,
|
|
205
|
-
onBehalfOf: input.onBehalfOf !== undefined ? { taskId: input.onBehalfOf } : undefined,
|
|
206
|
-
wake: true,
|
|
207
|
-
}),
|
|
208
|
-
...(input.ask ? { delegation: delegationPrompt(parent?.chain) } : {}),
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
async send(sessionID, plan, idempotencyKey, signal) {
|
|
212
|
-
const s = await this.ctx.session(sessionID, signal);
|
|
213
|
-
const to = formatToolAddress(plan.to);
|
|
214
|
-
if ("target" in plan) {
|
|
215
|
-
const sent = s.link.call("msg.send", compact({ key: s.key, to: plan.target, kind: "message", parts: [{ text: plan.text }], replyTo: plan.thread, request: plan.request, idempotencyKey }), signal);
|
|
216
|
-
// With a thread, not_found may be the thread's: it stays not_found.
|
|
217
|
-
const { results } = await (plan.thread === undefined ? unknownAs(sent, "not_found", "to") : sent);
|
|
218
|
-
return { id: results[0]?.envelopeId ?? "", conversation: plan.conversation, kind: plan.kind, results: [{ to, stage: "accepted" }], note: plan.request !== undefined ? NOTE.suggest : NOTE[plan.kind] };
|
|
219
|
-
}
|
|
220
|
-
const { results } = await unknownAs(s.link.call("msg.send", { ...plan.params, key: s.key, idempotencyKey }, signal), "no_recipients", "to");
|
|
221
|
-
const first = results[0];
|
|
222
|
-
const failed = first?.code !== undefined && (first.stage === "rejected" || first.stage === "expired");
|
|
223
|
-
const held = results.some((result) => result.stage === "held");
|
|
224
|
-
const note = failed
|
|
225
|
-
? `Not delivered (${first.code}). Next: ${TOOL_REMEDIES[first.code]}`
|
|
226
|
-
: held
|
|
227
|
-
? NOTE.held
|
|
228
|
-
: (first?.expect !== undefined ? EXPECT_TEXT[first.expect] : NOTE.sent) + (plan.kind === "task" ? NOTE.task : "");
|
|
229
|
-
return {
|
|
230
|
-
id: first?.envelopeId ?? "",
|
|
231
|
-
conversation: plan.conversation,
|
|
232
|
-
kind: plan.kind,
|
|
233
|
-
...(first?.taskId !== undefined ? { taskId: first.taskId } : {}),
|
|
234
|
-
results: results.map((result) => compact({ to: result.to, stage: result.stage, code: result.code, expect: result.stage === "held" ? "on_consent" : result.expect })),
|
|
235
|
-
note,
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* What `replyTo` names, for a send to the agent `to`: a message it sent this session (the reply
|
|
240
|
-
* joins its conversation), or a task this session asked it to run (the text is input for it).
|
|
241
|
-
*/
|
|
242
|
-
async replyOf(s, to, id, ask, signal) {
|
|
243
|
-
const item = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
|
|
244
|
-
if (item) {
|
|
245
|
-
const from = item.item.from;
|
|
246
|
-
if (from.person !== to.person || from.agent !== to.session)
|
|
247
|
-
throw fail("invalid_request", `replyTo: that message is from ${from.address}; send the reply there`);
|
|
248
|
-
return compact({ replyTo: item.item.id, contextId: item.item.contextId });
|
|
249
|
-
}
|
|
250
|
-
const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
|
|
251
|
-
if (!record || record.key !== s.key)
|
|
252
|
-
throw fail("not_found", "replyTo: no message or task with this id belongs to this session");
|
|
253
|
-
if (ask)
|
|
254
|
-
throw fail("invalid_request", "replyTo: a task request answers a message, not a task");
|
|
255
|
-
if (record.role !== "requester")
|
|
256
|
-
throw fail("invalid_state", "replyTo: you run this task; report on it with messaging_task_update");
|
|
257
|
-
if (record.peer.person !== to.person || record.peer.agent !== to.session)
|
|
258
|
-
throw fail("invalid_state", `replyTo: that task runs at ${record.peer.address}; send the input there`);
|
|
259
|
-
return { taskId: record.task.id };
|
|
260
|
-
}
|
|
261
|
-
/** The task a send `onBehalfOf` delegates for: this session runs it and it is not finished (spec §6.7). */
|
|
262
|
-
async delegationParent(s, taskId, signal) {
|
|
263
|
-
const record = await this.ownTask(s, taskId, "onBehalfOf", signal);
|
|
264
|
-
if (record.role !== "executor")
|
|
265
|
-
throw fail("invalid_state", "onBehalfOf: name a task this session runs; this session asked for it");
|
|
266
|
-
if (isTerminal(record.task.status.state))
|
|
267
|
-
throw fail("invalid_state", `onBehalfOf: the task is already ${taskStateName(record.task.status.state)}`);
|
|
268
|
-
return record;
|
|
269
|
-
}
|
|
270
|
-
// ---- inbox ----
|
|
271
|
-
/** Lists items; previews put in front of the agent are acknowledged `handed_to_agent` (spec §8.2). */
|
|
272
|
-
async inbox(sessionID, input, signal) {
|
|
273
|
-
const s = await this.ctx.session(sessionID, signal);
|
|
274
|
-
const unreadOnly = input.unread ?? true;
|
|
275
|
-
const limit = input.limit ?? DEFAULT_INBOX;
|
|
276
|
-
const from = input.from !== undefined ? parseToolAddress(input.from, s.person, "from") : undefined;
|
|
277
|
-
if (from?.kind === "channel")
|
|
278
|
-
throw fail("invalid_request", "from: channels hold no mail; read one with messaging_read {with}");
|
|
279
|
-
const filtered = from !== undefined || s.peer !== undefined;
|
|
280
|
-
const listed = await s.link.call("inbox.list", { key: s.key, unreadOnly, limit: filtered ? PAGE : limit }, signal);
|
|
281
|
-
const keep = (item) => (s.peer === undefined || item.from.person === s.peer) && (from === undefined || fromMatches(item, from, s.person));
|
|
282
|
-
const items = listed.items.filter(keep).slice(0, limit);
|
|
283
|
-
const shown = items.filter((item) => item.stage === "delivered");
|
|
284
|
-
for (const item of shown)
|
|
285
|
-
this.ctx.hooks.shown(item.id);
|
|
286
|
-
await Promise.all(shown.map((item) => this.ctx.hooks.ack(s.link, s.key, item.id)));
|
|
287
|
-
let unread;
|
|
288
|
-
if (unreadOnly && !filtered && listed.items.length < limit)
|
|
289
|
-
unread = listed.items.length;
|
|
290
|
-
else
|
|
291
|
-
unread = (await s.link.call("inbox.list", { key: s.key, unreadOnly: true, limit: PAGE }, signal)).items.filter((item) => s.peer === undefined || item.from.person === s.peer).length;
|
|
292
|
-
return {
|
|
293
|
-
items: items.map((item) => presentInboxItem(this.framer, { ...item, stage: item.stage === "delivered" ? "handed_to_agent" : item.stage }, item.consent ?? this.consent(item.from.person, s))),
|
|
294
|
-
unread,
|
|
295
|
-
...(items.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
296
|
-
};
|
|
297
|
-
}
|
|
298
123
|
// ---- read ----
|
|
299
124
|
/**
|
|
300
|
-
|
|
301
|
-
* receipt), a task this session asked for or runs, or something it sent (how far it got).
|
|
302
|
-
* `assert` gets the item's conversation before anything changes or is shown.
|
|
125
|
+
* Read a retained task or one of its received envelopes. The peer check precedes any side effect.
|
|
303
126
|
*/
|
|
304
|
-
async readItem(sessionID, id,
|
|
127
|
+
async readItem(sessionID, id, assertPeer, signal) {
|
|
305
128
|
const s = await this.ctx.session(sessionID, signal);
|
|
306
129
|
const got = await found(s.link.call("inbox.get", { key: s.key, id }, signal));
|
|
307
130
|
if (got) {
|
|
308
131
|
scoped(s.peer, got.item.from.person);
|
|
309
|
-
|
|
132
|
+
if (got.item.cid !== undefined || got.item.taskId === undefined)
|
|
133
|
+
throw fail("not_found", "id: not a retained task envelope");
|
|
134
|
+
await assertPeer(got.item.from.person);
|
|
310
135
|
const result = await s.link.call("item.read", { key: s.key, id }, signal);
|
|
311
136
|
if (result.kind !== "received")
|
|
312
137
|
throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
|
|
313
138
|
this.ctx.hooks.read(id);
|
|
314
139
|
const consent = result.item.consent ?? this.consent(result.item.from.person, s);
|
|
315
|
-
const taskId = result.item.
|
|
140
|
+
const taskId = result.item.taskId ?? result.message.taskId;
|
|
316
141
|
const record = taskId !== undefined ? await found(s.link.call("task.get", { key: s.key, taskId }, signal)) : undefined;
|
|
317
142
|
if (record)
|
|
318
143
|
this.ctx.hooks.sawTask(record);
|
|
319
|
-
return { item:
|
|
144
|
+
return { item: presentTaskEnvelope(this.framer, result, record ? presentTaskView(this.framer, record, consent) : undefined), notice: this.framer.notice() };
|
|
320
145
|
}
|
|
321
146
|
const record = await found(s.link.call("task.get", { key: s.key, taskId: id }, signal));
|
|
322
147
|
if (record && record.key === s.key) {
|
|
323
148
|
scoped(s.peer, record.peer.person);
|
|
324
|
-
await
|
|
149
|
+
await assertPeer(record.peer.person);
|
|
325
150
|
this.ctx.hooks.sawTask(record);
|
|
326
151
|
return { item: presentTaskItem(this.framer, record, s.address, this.consent(record.peer.person, s)), notice: this.framer.notice() };
|
|
327
152
|
}
|
|
328
|
-
|
|
329
|
-
const sent = item?.kind === "sent" ? item : undefined;
|
|
330
|
-
if (sent) {
|
|
331
|
-
scoped(s.peer, sent.to.slice(0, sent.to.indexOf("/")));
|
|
332
|
-
const item = presentSent(sent, s.address);
|
|
333
|
-
await assert(item.conversation);
|
|
334
|
-
return { item };
|
|
335
|
-
}
|
|
336
|
-
throw fail("not_found", "id: no message, task or sent item with this id belongs to this session");
|
|
337
|
-
}
|
|
338
|
-
/**
|
|
339
|
-
* A page of one conversation, oldest first: the chat lines with a person or in a channel, or the
|
|
340
|
-
* mail and tasks with an agent. Mail previews shown here count as handed to the agent.
|
|
341
|
-
*/
|
|
342
|
-
async readConversation(sessionID, input, assert, signal) {
|
|
343
|
-
const s = await this.ctx.session(sessionID, signal);
|
|
344
|
-
const named = parseToolAddress(input.with, s.person, "with");
|
|
345
|
-
const target = asPerson(named, s.person);
|
|
346
|
-
if (s.peer !== undefined && (target.kind === "channel" || target.person !== s.peer))
|
|
347
|
-
scoped(s.peer, "");
|
|
348
|
-
if (input.before !== undefined && input.after !== undefined)
|
|
349
|
-
throw fail("invalid_request", "before, after: pass one, not both");
|
|
350
|
-
if (target.kind !== "agent")
|
|
351
|
-
this.reachable(target, "with");
|
|
352
|
-
const conversation = conversationOf(target, s.person);
|
|
353
|
-
const cursor = input.before ?? input.after;
|
|
354
|
-
const place = cursor !== undefined ? decodeToolCursor(cursor, input.before !== undefined ? "before" : "after") : undefined;
|
|
355
|
-
const limit = input.limit ?? DEFAULT_PAGE;
|
|
356
|
-
await assert(conversation);
|
|
357
|
-
if (target.kind !== "agent") {
|
|
358
|
-
if (place?.kind === "conv")
|
|
359
|
-
throw fail("invalid_request", "before, after: that cursor is from another conversation");
|
|
360
|
-
const room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
|
|
361
|
-
const seq = place?.seq;
|
|
362
|
-
const page = await unknownAs(s.link.call("conv.read", compact({ conversation: room, limit, before: input.before !== undefined ? seq : undefined, after: input.after !== undefined ? seq : undefined }), signal), "not_found", "with");
|
|
363
|
-
if (page.kind !== "lines")
|
|
364
|
-
throw fail("internal", "with: the daemon answered a DM or channel page with agent entries");
|
|
365
|
-
const { lines: messages, more } = page;
|
|
366
|
-
return this.page(conversation, messages, (line) => presentLine(this.framer, line, target.kind === "person" ? "line" : "post", conversation), (line) => ({ kind: "seq", seq: line.seq }), more);
|
|
367
|
-
}
|
|
368
|
-
if (place?.kind === "seq")
|
|
369
|
-
throw fail("invalid_request", "before, after: that cursor is from another conversation");
|
|
370
|
-
const at = place?.kind === "conv" ? { at: place.at, id: place.id } : undefined;
|
|
371
|
-
const page = await s.link.call("conv.read", compact({ key: s.key, conversation: `agent:${target.person}/${target.session}`, limit, before: input.before !== undefined ? at : undefined, after: input.after !== undefined ? at : undefined }), signal);
|
|
372
|
-
if (page.kind !== "agent")
|
|
373
|
-
throw fail("internal", "with: the daemon answered an agent page with lines");
|
|
374
|
-
const { entries, more } = page;
|
|
375
|
-
const shown = entries.filter((entry) => entry.kind === "mail" && entry.direction === "in" && entry.stage === "delivered");
|
|
376
|
-
for (const entry of shown)
|
|
377
|
-
this.ctx.hooks.shown(entry.id);
|
|
378
|
-
await Promise.all(shown.map((entry) => this.ctx.hooks.ack(s.link, s.key, entry.id)));
|
|
379
|
-
return this.page(conversation, entries, (entry) => presentConvEntry(this.framer, entry, s.address), (entry) => ({ kind: "conv", at: entry.at, id: entry.id }), more);
|
|
380
|
-
}
|
|
381
|
-
page(conversation, rows, present, place, more) {
|
|
382
|
-
const first = rows[0];
|
|
383
|
-
const last = rows.at(-1);
|
|
384
|
-
return {
|
|
385
|
-
conversation,
|
|
386
|
-
entries: rows.map(present),
|
|
387
|
-
...(first !== undefined ? { before: encodeToolCursor(place(first)) } : {}),
|
|
388
|
-
...(last !== undefined ? { after: encodeToolCursor(place(last)) } : {}),
|
|
389
|
-
more,
|
|
390
|
-
...(rows.length > 0 ? { notice: this.framer.notice() } : {}),
|
|
391
|
-
};
|
|
392
|
-
}
|
|
393
|
-
// ---- search ----
|
|
394
|
-
/** Chat lines with persons and in channels. Each conversation in the hits is asserted; `allowed` false drops it. */
|
|
395
|
-
async search(sessionID, input, allowed, signal) {
|
|
396
|
-
const s = await this.ctx.session(sessionID, signal);
|
|
397
|
-
let room;
|
|
398
|
-
if (input.with !== undefined) {
|
|
399
|
-
const named = parseToolAddress(input.with, s.person, "with");
|
|
400
|
-
if (named.kind === "agent")
|
|
401
|
-
throw fail("invalid_request", "with: agent mail is not searched; page it with messaging_read {with}");
|
|
402
|
-
const target = asPerson(named, s.person);
|
|
403
|
-
this.reachable(target, "with");
|
|
404
|
-
room = target.kind === "person" ? `dm:${target.person}` : `channel:${target.name}`;
|
|
405
|
-
}
|
|
406
|
-
if (s.peer !== undefined)
|
|
407
|
-
room = room === undefined || room === `dm:${s.peer}` ? `dm:${s.peer}` : (scoped(s.peer, ""), room);
|
|
408
|
-
const channels = this.ctx.access.channels;
|
|
409
|
-
// A client limited to some channels searches each of them: an unscoped search would read the rest.
|
|
410
|
-
const rooms = room === undefined && channels !== undefined ? [...channels].map((name) => `channel:${name}`) : [room];
|
|
411
|
-
const limit = input.limit ?? 20;
|
|
412
|
-
const search = (one) => s.link.call("search", compact({ query: input.query, conversation: one, limit: input.limit }), signal);
|
|
413
|
-
// A named conversation the daemon does not know is unknown_address; an allowed channel this
|
|
414
|
-
// person is not in (or no longer) has nothing to find.
|
|
415
|
-
const pages = await Promise.all(rooms.map((one) => (room !== undefined ? unknownAs(search(one), "not_found", "with") : found(search(one)).then((page) => page ?? { hits: [] }))));
|
|
416
|
-
const messages = pages
|
|
417
|
-
.flatMap((page) => page.hits)
|
|
418
|
-
.filter((line) => (line.conversation.startsWith("dm:") ? this.ctx.access.dms : channels === undefined || channels.has(line.conversation.slice("channel:".length))))
|
|
419
|
-
.sort((a, b) => b.at - a.at)
|
|
420
|
-
.slice(0, limit);
|
|
421
|
-
const verdicts = new Map();
|
|
422
|
-
for (const conversation of new Set(messages.map((line) => roomConversation(line.conversation))))
|
|
423
|
-
verdicts.set(conversation, await allowed(conversation));
|
|
424
|
-
const hits = messages
|
|
425
|
-
.filter((line) => verdicts.get(roomConversation(line.conversation)))
|
|
426
|
-
.map((line) => {
|
|
427
|
-
const conversation = roomConversation(line.conversation);
|
|
428
|
-
const from = line.agent === undefined ? line.person : `${line.person}/${line.agent}`;
|
|
429
|
-
return { id: line.id, conversation, from, at: new Date(line.at).toISOString(), snippet: this.framer.wrap(line.body, { from, conversation, id: line.id }, 2_000) };
|
|
430
|
-
});
|
|
431
|
-
return { hits, ...(hits.length > 0 ? { notice: this.framer.notice() } : {}) };
|
|
153
|
+
throw fail("not_found", "id: no task or task envelope with this id belongs to this session");
|
|
432
154
|
}
|
|
433
155
|
// ---- tasks ----
|
|
434
156
|
async tasks(sessionID, input, signal) {
|
|
@@ -524,21 +246,6 @@ export class Operations {
|
|
|
524
246
|
function fail(code, message) {
|
|
525
247
|
throw new MessagingError(code, message);
|
|
526
248
|
}
|
|
527
|
-
/**
|
|
528
|
-
* A daemon call that names an address: the daemon's code for a person, agent or channel it does not
|
|
529
|
-
* know (`not_found` for a person or channel, `no_recipients` for an agent, from `msg.send`) is `unknown_address`
|
|
530
|
-
* to the agent (spec §11), naming the field.
|
|
531
|
-
*/
|
|
532
|
-
async function unknownAs(call, code, field) {
|
|
533
|
-
try {
|
|
534
|
-
return await call;
|
|
535
|
-
}
|
|
536
|
-
catch (error) {
|
|
537
|
-
if (error instanceof MessagingError && error.code === code)
|
|
538
|
-
fail("unknown_address", `${field}: no person, agent or channel by that name here`);
|
|
539
|
-
throw error;
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
249
|
/** The result, or undefined for `not_found`. */
|
|
543
250
|
async function found(call) {
|
|
544
251
|
try {
|
|
@@ -555,21 +262,6 @@ function scoped(peer, person) {
|
|
|
555
262
|
if (peer !== undefined && person !== peer)
|
|
556
263
|
throw fail("not_found", "nothing with this id or address concerns the teammate who started this turn");
|
|
557
264
|
}
|
|
558
|
-
function fromMatches(item, from, self) {
|
|
559
|
-
switch (from.kind) {
|
|
560
|
-
case "me":
|
|
561
|
-
return item.from.person === self;
|
|
562
|
-
case "person":
|
|
563
|
-
return item.from.person === from.person;
|
|
564
|
-
case "agent":
|
|
565
|
-
return item.from.person === from.person && item.from.agent === from.session && (from.label === undefined || item.from.label === from.label);
|
|
566
|
-
case "channel":
|
|
567
|
-
return false;
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
function roomConversation(room) {
|
|
571
|
-
return room.startsWith("dm:") ? `person:${room.slice("dm:".length)}` : room;
|
|
572
|
-
}
|
|
573
265
|
/** Media types sent as text parts: their content stays readable to an A2A client and to the recipient's agent. */
|
|
574
266
|
const TEXTUAL = /^(?:text\/[\w.+-]+|application\/(?:json|xml|yaml|x-yaml|javascript|x-sh|x-diff|x-patch|[\w.-]+\+(?:json|xml)))(?:\s*;.*)?$/i;
|
|
575
267
|
/**
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { taskStateName } from "../agent-tools.js";
|
|
2
|
+
import { MessagingError } from "../schema/errors.js";
|
|
2
3
|
import { createFence, PEER_FENCE_TAG, provenanceOf } from "../peer-text.js";
|
|
4
|
+
import { LIMITS } from "../schema/common.js";
|
|
5
|
+
import { TASK_REFUSAL_CHARS, TASK_SIGN_IN_TOOL_CHARS } from "../schema/ipc.js";
|
|
3
6
|
import { chainPath, EXPECT_TEXT, isSafeId, messageAttributes } from "./text.js";
|
|
4
7
|
/** Per-part cap in tool outputs; the protocol caps a text part at 65,536 chars. */
|
|
5
|
-
const PART_CHARS =
|
|
8
|
+
const PART_CHARS = LIMITS.toolTextChars;
|
|
6
9
|
/** Task history shown by `read`: the most recent messages. */
|
|
7
10
|
const HISTORY_MESSAGES = 20;
|
|
8
11
|
const ARTIFACTS = 20;
|
|
@@ -96,32 +99,31 @@ export function agentCan(entry) {
|
|
|
96
99
|
}
|
|
97
100
|
// ---- inbox ----
|
|
98
101
|
export function presentInboxItem(framer, item, consent) {
|
|
102
|
+
if (item.cid === undefined && item.taskId === undefined)
|
|
103
|
+
throw new MessagingError("invalid_request", "inbox item has no conversation or task source");
|
|
99
104
|
return {
|
|
100
105
|
id: item.id,
|
|
101
|
-
|
|
102
|
-
from: item.from.address,
|
|
103
|
-
kind: item.kind === "task" ? "task" : "mail",
|
|
106
|
+
source: item.cid !== undefined ? { kind: "conv", cid: item.cid, event: item.eventId } : { kind: "task", taskId: item.taskId },
|
|
107
|
+
from: framer.wrap(item.from.address, { from: "sender" }),
|
|
104
108
|
preview: framer.wrap(item.preview, messageAttributes(item)),
|
|
105
|
-
at:
|
|
109
|
+
at: item.receivedAt,
|
|
106
110
|
stage: item.stage,
|
|
107
111
|
consent,
|
|
108
|
-
...(item.taskId !== undefined ? { taskId: item.taskId } : {}),
|
|
109
112
|
...(item.chain !== undefined ? { chain: presentChain(framer, item.chain) } : {}),
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
|
-
/** The agent conversation with a peer: `agent:<person>/<agent>`, or `person:<person>` for the person's own. */
|
|
113
|
-
export function conversationOfPeer(peer) {
|
|
114
|
-
return peer.agent === undefined ? `person:${peer.person}` : `agent:${peer.person}/${peer.agent}`;
|
|
115
|
-
}
|
|
116
115
|
// ---- read ----
|
|
117
|
-
export function
|
|
116
|
+
export function presentTaskEnvelope(framer, result, task) {
|
|
118
117
|
const { item, message } = result;
|
|
118
|
+
const taskId = item.taskId ?? message.taskId;
|
|
119
|
+
if (item.cid !== undefined || taskId === undefined)
|
|
120
|
+
throw new MessagingError("invalid_request", "envelope does not name a task");
|
|
119
121
|
const attributes = messageAttributes(item, message);
|
|
120
122
|
const provenance = provenanceOf(message.metadata, item.id);
|
|
121
123
|
return {
|
|
122
124
|
id: item.id,
|
|
123
|
-
|
|
124
|
-
kind: item.kind === "task" ? "task" : "
|
|
125
|
+
source: { kind: "task", taskId },
|
|
126
|
+
kind: item.kind === "task" ? "task" : "continuation",
|
|
125
127
|
from: provenance?.from ?? item.from.address,
|
|
126
128
|
at: isoOf(item.receivedAt),
|
|
127
129
|
parts: message.parts.map((part) => presentPart(framer, part, "peer", attributes)),
|
|
@@ -137,7 +139,7 @@ export function presentTaskItem(framer, record, self, consent) {
|
|
|
137
139
|
const author = request ? historyAuthor(record, request) : "peer";
|
|
138
140
|
return {
|
|
139
141
|
id: record.task.id,
|
|
140
|
-
|
|
142
|
+
source: { kind: "task", taskId: record.task.id },
|
|
141
143
|
kind: "task",
|
|
142
144
|
from: author === "ours" ? self : record.peer.address,
|
|
143
145
|
at: isoOf(record.createdAt),
|
|
@@ -146,47 +148,6 @@ export function presentTaskItem(framer, record, self, consent) {
|
|
|
146
148
|
task: presentTaskView(framer, record, consent),
|
|
147
149
|
};
|
|
148
150
|
}
|
|
149
|
-
/** Something this session sent: no copy is kept, so only how far it got. */
|
|
150
|
-
export function presentSent(result, self) {
|
|
151
|
-
const [person = "", rest = ""] = result.to.split("/");
|
|
152
|
-
const session = rest.split("@")[0];
|
|
153
|
-
return {
|
|
154
|
-
id: result.envelopeId,
|
|
155
|
-
conversation: `agent:${person}/${session}`,
|
|
156
|
-
kind: "sent",
|
|
157
|
-
from: self,
|
|
158
|
-
at: isoOf(result.history[0]?.at ?? 0),
|
|
159
|
-
parts: [],
|
|
160
|
-
delivery: result.history.map((entry) => ({ to: result.to, stage: entry.stage, at: isoOf(entry.at), ...(entry.code !== undefined ? { code: entry.code } : {}) })),
|
|
161
|
-
};
|
|
162
|
-
}
|
|
163
|
-
// ---- conversation pages ----
|
|
164
|
-
export function presentLine(framer, line, kind, conversation) {
|
|
165
|
-
const from = line.agent === undefined ? line.person : `${line.person}/${line.agent}`;
|
|
166
|
-
return {
|
|
167
|
-
id: line.id,
|
|
168
|
-
from,
|
|
169
|
-
kind: line.notice ? "notice" : kind,
|
|
170
|
-
at: isoOf(line.at),
|
|
171
|
-
text: framer.wrap(line.body, { from, conversation, id: line.id }, 8_000),
|
|
172
|
-
...(line.thread !== undefined ? { thread: line.thread } : {}),
|
|
173
|
-
...(line.state !== undefined ? { stage: line.state } : {}),
|
|
174
|
-
};
|
|
175
|
-
}
|
|
176
|
-
export function presentConvEntry(framer, entry, self) {
|
|
177
|
-
const author = entry.direction === "out" ? "ours" : "peer";
|
|
178
|
-
const from = author === "ours" ? self : entry.peer;
|
|
179
|
-
return {
|
|
180
|
-
id: entry.id,
|
|
181
|
-
from,
|
|
182
|
-
kind: entry.kind,
|
|
183
|
-
at: isoOf(entry.at),
|
|
184
|
-
...(entry.text !== undefined ? { author, text: shown(framer, author, { from, id: entry.id })(entry.text) } : {}),
|
|
185
|
-
...(entry.taskId !== undefined ? { taskId: entry.taskId } : {}),
|
|
186
|
-
...(entry.stage !== undefined ? { stage: entry.stage } : {}),
|
|
187
|
-
...(entry.state !== undefined ? { state: taskStateName(entry.state) } : {}),
|
|
188
|
-
};
|
|
189
|
-
}
|
|
190
151
|
// ---- tasks ----
|
|
191
152
|
function textOf(message) {
|
|
192
153
|
if (!message)
|
|
@@ -211,6 +172,10 @@ export function presentTask(framer, record, consent) {
|
|
|
211
172
|
state: taskStateName(record.task.status.state),
|
|
212
173
|
...(title !== undefined ? { title: shown(framer, titleAuthor, peerAttributes(record))(title, TITLE_CHARS), titleAuthor } : {}),
|
|
213
174
|
...(status !== undefined ? { status: shown(framer, statusAuthor, peerAttributes(record))(status, PART_CHARS), statusAuthor } : {}),
|
|
175
|
+
...(record.signIn !== undefined
|
|
176
|
+
? { signIn: { ...(record.signIn.tool !== undefined ? { tool: shown(framer, statusAuthor, peerAttributes(record))(record.signIn.tool, TASK_SIGN_IN_TOOL_CHARS) } : {}), by: record.signIn.by } }
|
|
177
|
+
: {}),
|
|
178
|
+
...(record.refusal !== undefined ? { refusal: { reason: shown(framer, statusAuthor, peerAttributes(record))(record.refusal.reason, TASK_REFUSAL_CHARS) } } : {}),
|
|
214
179
|
updatedAt: isoOf(record.updatedAt),
|
|
215
180
|
...(consent !== undefined && record.role === "executor" ? { consent } : {}),
|
|
216
181
|
...(record.chain !== undefined ? { chain: presentChain(framer, record.chain) } : {}),
|