a2a-exposed 0.8.17 → 0.8.18
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/lib/commands.mjs +4 -1
- package/lib/main.mjs +9 -6
- package/package.json +1 -1
- package/worker/migrations/0012_conversation_peer_owner.sql +13 -0
- package/worker/src/index.ts +146 -40
- package/worker/src/mcp.ts +2 -1
- package/worker/src/migrations.ts +2 -0
- package/worker/src/owner-rpc.ts +11 -5
- package/worker/src/version.ts +1 -1
- package/worker/src/wake.ts +4 -2
- package/worker/test/context-peer.test.ts +198 -79
- package/worker/test/facade.test.ts +6 -2
- package/worker/test/list-tasks.test.ts +120 -0
- package/worker/test/migrations.test.ts +18 -6
- package/worker/test/owner-rpc.test.ts +4 -0
- package/worker/test/push-config.test.ts +161 -0
- package/worker/test/wake.test.ts +2 -2
package/lib/commands.mjs
CHANGED
|
@@ -55,6 +55,7 @@ async function inboxRows(o) {
|
|
|
55
55
|
return followInbox(async (cursor) => {
|
|
56
56
|
const qs = [];
|
|
57
57
|
if (o.context) qs.push("context=" + q(o.context));
|
|
58
|
+
if (o.from) qs.push("from=" + q(o.from));
|
|
58
59
|
if (o.all) qs.push("all=1");
|
|
59
60
|
if (cursor) qs.push("cursor=" + q(cursor));
|
|
60
61
|
const path = "/owner/inbox" + (qs.length ? "?" + qs.join("&") : "");
|
|
@@ -125,7 +126,9 @@ export async function reply(id, o) {
|
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
export async function history(ctx, o) {
|
|
128
|
-
const
|
|
129
|
+
const qs = new URLSearchParams({ n: String(Number(o.n || 50)) });
|
|
130
|
+
if (o.from) qs.set("from", o.from);
|
|
131
|
+
const rows = await owner("GET", `/owner/history/${q(ctx)}?${qs}`);
|
|
129
132
|
if (o.json) return out(rows);
|
|
130
133
|
for (const e of rows) {
|
|
131
134
|
const tag = [e.event, e.state || e.role].filter(Boolean).join("/");
|
package/lib/main.mjs
CHANGED
|
@@ -165,9 +165,11 @@ Status
|
|
|
165
165
|
config print config (secrets masked) and its location
|
|
166
166
|
|
|
167
167
|
Inbox (owner side)
|
|
168
|
-
inbox [--context C] [--all] [--json]
|
|
169
|
-
|
|
170
|
-
|
|
168
|
+
inbox [--context C] [--from PEER] [--all] [--json]
|
|
169
|
+
unhandled inbound tasks, and a reply to a message you sent until
|
|
170
|
+
this list is read (peer text is UNTRUSTED). --from is one peer
|
|
171
|
+
(the wake hint includes it). A reply line says to read it with
|
|
172
|
+
history and continue with send. Then pending pairing
|
|
171
173
|
requests (tell your human; the dashboard URL when MANAGE_URL is set;
|
|
172
174
|
--json: rows on stdout, requests on stderr). Follows every page; past
|
|
173
175
|
10000 pages it errors instead of a short list
|
|
@@ -175,7 +177,8 @@ Inbox (owner side)
|
|
|
175
177
|
working <taskId> mark task working
|
|
176
178
|
reply <taskId> (--text T | --stdin) [--state completed|input-required|rejected|failed|working]
|
|
177
179
|
[--artifact] [--artifact-name N] [--force]
|
|
178
|
-
history <contextId> [-n 50] [--json]
|
|
180
|
+
history <contextId> [--from PEER] [-n 50] [--json]
|
|
181
|
+
conversation log. --from is one peer (the wake hint includes it)
|
|
179
182
|
contexts recent conversations
|
|
180
183
|
export [--sql] JSON dump of this A2A endpoint on stdout (peer-token hashes and
|
|
181
184
|
encrypted peer tokens are in the dump; the owner token stays
|
|
@@ -312,9 +315,9 @@ const deployOpts = {
|
|
|
312
315
|
};
|
|
313
316
|
const SPEC = {
|
|
314
317
|
init: { ...deployOpts, ...tunnelOpts, tunnel: B }, deploy: deployOpts, wake: deployOpts, tunnel: tunnelOpts,
|
|
315
|
-
inbox: { context: S, all: B, json: B },
|
|
318
|
+
inbox: { context: S, from: S, all: B, json: B },
|
|
316
319
|
reply: { text: S, stdin: B, state: { type: "string", default: "completed" }, artifact: B, "artifact-name": { type: "string", default: "response" }, force: B },
|
|
317
|
-
history: { n: { type: "string", short: "n", default: "50" }, json: B },
|
|
320
|
+
history: { from: S, n: { type: "string", short: "n", default: "50" }, json: B },
|
|
318
321
|
token: { rotate: B },
|
|
319
322
|
peers: { "token-env": S, "token-stdin": B },
|
|
320
323
|
send: { to: S, text: S, context: S, task: S, push: B, proto: S },
|
package/package.json
CHANGED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
-- One row per peer. The same context id can be a separate conversation for each peer.
|
|
2
|
+
-- 0011's single-column primary key made a second peer's insert fail, which is how a caller
|
|
3
|
+
-- could tell a fresh id from one another peer already held.
|
|
4
|
+
CREATE TABLE conversation_owners_peer (
|
|
5
|
+
context_id TEXT NOT NULL,
|
|
6
|
+
peer TEXT NOT NULL,
|
|
7
|
+
created_at TEXT NOT NULL,
|
|
8
|
+
PRIMARY KEY (context_id, peer)
|
|
9
|
+
);
|
|
10
|
+
INSERT INTO conversation_owners_peer (context_id, peer, created_at)
|
|
11
|
+
SELECT context_id, peer, created_at FROM conversation_owners;
|
|
12
|
+
DROP TABLE conversation_owners;
|
|
13
|
+
ALTER TABLE conversation_owners_peer RENAME TO conversation_owners;
|
package/worker/src/index.ts
CHANGED
|
@@ -67,17 +67,12 @@ function histStmt(ctx: TenantContext, contextId: string, e: Json) {
|
|
|
67
67
|
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
/** Claim
|
|
71
|
-
*
|
|
70
|
+
/** Claim this peer's conversation. The primary key is (context_id, peer): another peer's row is a
|
|
71
|
+
* different conversation and is not read or replaced. The insert does nothing when this peer already holds the id. */
|
|
72
72
|
function claimContextStmt(ctx: TenantContext, contextId: string, peer: string, ts: string) {
|
|
73
73
|
return ctx.DB.prepare(
|
|
74
|
-
`INSERT INTO conversation_owners (context_id, peer, created_at)
|
|
75
|
-
|
|
76
|
-
WHERE NOT EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer != ?2)
|
|
77
|
-
AND NOT EXISTS (SELECT 1 FROM tasks WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
78
|
-
AND NOT EXISTS (SELECT 1 FROM history WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
79
|
-
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?1 AND peer IS NOT NULL AND peer != '' AND peer != ?2)
|
|
80
|
-
ON CONFLICT(context_id) DO NOTHING`,
|
|
74
|
+
`INSERT INTO conversation_owners (context_id, peer, created_at) VALUES (?1, ?2, ?3)
|
|
75
|
+
ON CONFLICT(context_id, peer) DO NOTHING`,
|
|
81
76
|
).bind(contextId, peer, ts);
|
|
82
77
|
}
|
|
83
78
|
|
|
@@ -86,9 +81,9 @@ function saveTaskIfOwnerStmt(ctx: TenantContext, t: Json) {
|
|
|
86
81
|
return ctx.DB.prepare(
|
|
87
82
|
`INSERT INTO tasks (id, context_id, peer, state, protocol, created_at, updated_at, status_json, artifacts_json, metadata_json, push_configs_json)
|
|
88
83
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11
|
|
89
|
-
WHERE (SELECT
|
|
84
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)
|
|
90
85
|
ON CONFLICT(id) DO UPDATE SET state=?4, updated_at=?7, status_json=?8, artifacts_json=?9, metadata_json=?10, push_configs_json=?11
|
|
91
|
-
WHERE (SELECT
|
|
86
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?2 AND peer = ?3)`,
|
|
92
87
|
).bind(t.id, t.contextId, t._peer, t.status.state, t._protocol, t._createdAt, A.nowIso(), JSON.stringify(t.status),
|
|
93
88
|
JSON.stringify(t.artifacts || []), t.metadata ? JSON.stringify(t.metadata) : null, JSON.stringify(t._push || []));
|
|
94
89
|
}
|
|
@@ -97,7 +92,7 @@ function msgIfOwnerStmt(ctx: TenantContext, t: Json, m: Json) {
|
|
|
97
92
|
return ctx.DB.prepare(
|
|
98
93
|
`INSERT INTO messages (message_id, task_id, context_id, role, ts, message_json)
|
|
99
94
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6
|
|
100
|
-
WHERE (SELECT
|
|
95
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?3 AND peer = ?7)`,
|
|
101
96
|
).bind(m.messageId, t.id, t.contextId, m.role, A.nowIso(), JSON.stringify(m), t._peer);
|
|
102
97
|
}
|
|
103
98
|
|
|
@@ -105,7 +100,7 @@ function histIfOwnerStmt(ctx: TenantContext, contextId: string, e: Json, owner:
|
|
|
105
100
|
return ctx.DB.prepare(
|
|
106
101
|
`INSERT INTO history (context_id, ts, dir, peer, task_id, role, state, event, text, data_json)
|
|
107
102
|
SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10
|
|
108
|
-
WHERE (SELECT
|
|
103
|
+
WHERE EXISTS (SELECT 1 FROM conversation_owners WHERE context_id = ?1 AND peer = ?11)`,
|
|
109
104
|
).bind(contextId, e.ts || A.nowIso(), e.dir || "local", e.peer ?? null, e.taskId ?? null, e.role ?? null, e.state ?? null,
|
|
110
105
|
e.event ?? null, e.text ?? null, e.data !== undefined ? JSON.stringify(e.data) : null, owner);
|
|
111
106
|
}
|
|
@@ -635,15 +630,15 @@ function protectedResourceMetadata(ctx: TenantContext): Json {
|
|
|
635
630
|
const V1: Record<string, string> = {
|
|
636
631
|
SendMessage: "send", GetTask: "get", CancelTask: "cancel", CreateTaskPushNotificationConfig: "pushset",
|
|
637
632
|
SetTaskPushNotificationConfig: "pushset", GetTaskPushNotificationConfig: "pushget", ListTasks: "list",
|
|
633
|
+
ListTaskPushNotificationConfig: "pushlist", ListTaskPushNotificationConfigs: "pushlist",
|
|
634
|
+
DeleteTaskPushNotificationConfig: "pushdel",
|
|
638
635
|
SendStreamingMessage: "stream", SubscribeToTask: "stream",
|
|
639
636
|
};
|
|
640
637
|
const V03: Record<string, string> = {
|
|
641
638
|
"message/send": "send", "tasks/get": "get", "tasks/cancel": "cancel", "tasks/pushNotificationConfig/set": "pushset",
|
|
642
|
-
"tasks/pushNotificationConfig/get": "pushget", "
|
|
639
|
+
"tasks/pushNotificationConfig/get": "pushget", "tasks/pushNotificationConfig/list": "pushlist",
|
|
640
|
+
"tasks/pushNotificationConfig/delete": "pushdel", "message/stream": "stream", "tasks/resubscribe": "stream",
|
|
643
641
|
};
|
|
644
|
-
// Methods only the façade forwards (the inbox has one push config per task and no delete).
|
|
645
|
-
const PROXY_V1: Record<string, string> = { ListTaskPushNotificationConfig: "pushlist", DeleteTaskPushNotificationConfig: "pushdel" };
|
|
646
|
-
const PROXY_V03: Record<string, string> = { "tasks/pushNotificationConfig/list": "pushlist", "tasks/pushNotificationConfig/delete": "pushdel" };
|
|
647
642
|
const EXTENDED_CARD_METHODS = new Set(["GetExtendedAgentCard", "agent/getAuthenticatedExtendedCard"]);
|
|
648
643
|
|
|
649
644
|
/** Header, else query `A2A-Version`. A patch (`1.0.1`, `0.3.0`) is ignored. Empty means the method name chooses the
|
|
@@ -659,11 +654,9 @@ function declaredA2AVersion(req: Request): { raw: string; version: string | null
|
|
|
659
654
|
return { raw, version: null };
|
|
660
655
|
}
|
|
661
656
|
|
|
662
|
-
function rpcOperation(method: string
|
|
657
|
+
function rpcOperation(method: string): { op: string; version: string } | null {
|
|
663
658
|
if (V1[method]) return { op: V1[method], version: "1.0" };
|
|
664
659
|
if (V03[method]) return { op: V03[method], version: "0.3" };
|
|
665
|
-
if (proxy && PROXY_V1[method]) return { op: PROXY_V1[method], version: "1.0" };
|
|
666
|
-
if (proxy && PROXY_V03[method]) return { op: PROXY_V03[method], version: "0.3" };
|
|
667
660
|
return null;
|
|
668
661
|
}
|
|
669
662
|
|
|
@@ -710,10 +703,11 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
710
703
|
}
|
|
711
704
|
const contextId = task ? task.contextId : msg.contextId ? A.checkId(msg.contextId, "contextId") : A.newId();
|
|
712
705
|
const ts = A.nowIso();
|
|
713
|
-
//
|
|
714
|
-
//
|
|
715
|
-
//
|
|
716
|
-
//
|
|
706
|
+
// A2A lets an agent accept a client-supplied contextId and forbids minting a replacement when it will not.
|
|
707
|
+
// This inbox accepts the id for the caller. A peer that already has it continues that conversation. An id
|
|
708
|
+
// this agent has never seen, and an id another peer already has, are the same for the caller: a new
|
|
709
|
+
// conversation of their own. The other peer's tasks, history, and wake are not read or written. The claim
|
|
710
|
+
// and the first task are one batch, so the task is stored only when this peer's owner row is present.
|
|
717
711
|
const creating = !task;
|
|
718
712
|
if (!task) task = { id: A.newId(), contextId, status: { state: "submitted", timestamp: ts }, artifacts: [], history: [], _peer: label, _protocol: version, _createdAt: ts, _push: [] };
|
|
719
713
|
msg.contextId = contextId; msg.taskId = task.id;
|
|
@@ -728,11 +722,9 @@ const rpcSend: Rpc = async (params, label, version, ctx, ectx) => {
|
|
|
728
722
|
saveTaskIfOwnerStmt(ctx, task),
|
|
729
723
|
msgIfOwnerStmt(ctx, task, msg),
|
|
730
724
|
histIfOwnerStmt(ctx, contextId, { dir: "in", peer: label, taskId: task.id, role: "user", text: preview, data: { messageId: msg.messageId } }, label),
|
|
731
|
-
ctx.DB.prepare("SELECT peer FROM conversation_owners WHERE context_id = ?").bind(contextId),
|
|
732
725
|
]);
|
|
733
|
-
const owner = (stored[4]?.results?.[0] as Json | undefined)?.peer;
|
|
734
726
|
const wrote = stored[1]?.meta?.changes ?? 0;
|
|
735
|
-
if (
|
|
727
|
+
if (wrote < 1) throw new RpcError(-32602, UNKNOWN_CONTEXT);
|
|
736
728
|
} else {
|
|
737
729
|
await ctx.DB.batch([
|
|
738
730
|
saveTaskStmt(ctx, task), msgStmt(ctx, task, msg),
|
|
@@ -774,22 +766,104 @@ const rpcPushGet: Rpc = async (p, label, version, ctx) => {
|
|
|
774
766
|
throw new RpcError(-32001, "Push notification config not found");
|
|
775
767
|
};
|
|
776
768
|
|
|
769
|
+
/** Config id for list/delete. 1.0 sends `{ taskId, id }`. 0.3 sends `{ id: taskId, pushNotificationConfigId }`. */
|
|
770
|
+
function pushConfigId(p: Json): string | undefined {
|
|
771
|
+
const id = p.pushNotificationConfigId || (p.taskId ? p.id : undefined);
|
|
772
|
+
return typeof id === "string" && id ? id : undefined;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Cursor over this task's configs. The token is an index, not a config id, url, or token. */
|
|
776
|
+
function pushListStart(raw: unknown): number {
|
|
777
|
+
if (raw === undefined || raw === null || raw === "") return 0;
|
|
778
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,64}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
779
|
+
let text = "";
|
|
780
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
781
|
+
const m = /^cfg\n(\d{1,6})$/.exec(text);
|
|
782
|
+
if (!m) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
783
|
+
return Number(m[1]);
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const rpcPushList: Rpc = async (p, label, version, ctx) => {
|
|
787
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
788
|
+
const all = (t._push || []).map((c: Json) => pubCfg(t.id, c, version));
|
|
789
|
+
if (!version.startsWith("1")) return all;
|
|
790
|
+
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
791
|
+
const start = pushListStart(p.pageToken);
|
|
792
|
+
const configs = all.slice(start, start + size);
|
|
793
|
+
const next = start + configs.length;
|
|
794
|
+
return { configs, nextPageToken: next < all.length ? b64url(`cfg\n${next}`) : "" };
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
const rpcPushDel: Rpc = async (p, label, version, ctx) => {
|
|
798
|
+
const t = await ownedTask(ctx, p.taskId || p.id, label);
|
|
799
|
+
const id = pushConfigId(p);
|
|
800
|
+
if (!id) throw new RpcError(-32602, "Invalid params: push notification config id");
|
|
801
|
+
const kept = (t._push || []).filter((c: Json) => c.id !== id);
|
|
802
|
+
if (kept.length !== (t._push || []).length) {
|
|
803
|
+
t._push = kept;
|
|
804
|
+
await saveTaskStmt(ctx, t).run();
|
|
805
|
+
}
|
|
806
|
+
// Idempotent: a missing config on this peer's task is the same as a config already removed.
|
|
807
|
+
return version.startsWith("1") ? {} : null;
|
|
808
|
+
};
|
|
809
|
+
|
|
810
|
+
/** Keyset cursor for ListTasks, newest update first. Empty means the first page. A bad token is rejected
|
|
811
|
+
* without echoing it. The query still filters by the caller, so the cursor cannot select another peer's tasks. */
|
|
812
|
+
function listCursor(raw: unknown): { updatedAt: string; id: string } | null {
|
|
813
|
+
if (raw === undefined || raw === null || raw === "") return null;
|
|
814
|
+
if (typeof raw !== "string" || !/^[A-Za-z0-9_-]{1,256}$/.test(raw)) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
815
|
+
let text = "";
|
|
816
|
+
try { text = b64urlDecode(raw); } catch { throw new RpcError(-32602, "Invalid params: pageToken"); }
|
|
817
|
+
const nl = text.indexOf("\n");
|
|
818
|
+
if (nl <= 0) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
819
|
+
const updatedAt = text.slice(0, nl);
|
|
820
|
+
const id = text.slice(nl + 1);
|
|
821
|
+
if (!INBOX_STAMP.test(updatedAt) || !A.ID_RE.test(id) || id.includes("\n")) throw new RpcError(-32602, "Invalid params: pageToken");
|
|
822
|
+
return { updatedAt, id };
|
|
823
|
+
}
|
|
824
|
+
|
|
777
825
|
const rpcList: Rpc = async (p, label, version, ctx) => {
|
|
778
826
|
const size = Math.min(Math.max(Number(p.pageSize) || 50, 1), 100);
|
|
779
|
-
const
|
|
780
|
-
const
|
|
781
|
-
const
|
|
782
|
-
|
|
827
|
+
const cursor = listCursor(p.pageToken);
|
|
828
|
+
const where = ["peer = ?"];
|
|
829
|
+
const args: Json[] = [label];
|
|
830
|
+
if (p.contextId !== undefined && p.contextId !== "") {
|
|
831
|
+
if (typeof p.contextId !== "string" || !A.ID_RE.test(p.contextId)) throw new RpcError(-32602, "Invalid params: invalid contextId");
|
|
832
|
+
where.push("context_id = ?");
|
|
833
|
+
args.push(p.contextId);
|
|
834
|
+
}
|
|
835
|
+
const whereSql = where.join(" AND ");
|
|
836
|
+
const total: Json = await ctx.DB.prepare(`SELECT COUNT(*) AS n FROM tasks WHERE ${whereSql}`).bind(...args).first();
|
|
837
|
+
const pageWhere = [...where];
|
|
838
|
+
const pageArgs = [...args];
|
|
839
|
+
if (cursor) {
|
|
840
|
+
pageWhere.push("(updated_at < ? OR (updated_at = ? AND id < ?))");
|
|
841
|
+
pageArgs.push(cursor.updatedAt, cursor.updatedAt, cursor.id);
|
|
842
|
+
}
|
|
843
|
+
const rows = await ctx.DB.prepare(
|
|
844
|
+
`SELECT id, updated_at FROM tasks WHERE ${pageWhere.join(" AND ")} ORDER BY updated_at DESC, id DESC LIMIT ?`,
|
|
845
|
+
).bind(...pageArgs, size + 1).all();
|
|
846
|
+
const found = (rows.results || []) as Json[];
|
|
847
|
+
const page = found.slice(0, size);
|
|
783
848
|
const tasks = [];
|
|
784
|
-
for (const r of
|
|
849
|
+
for (const r of page) {
|
|
785
850
|
const t = A.publicTask(await loadTask(ctx, r.id), version, p.historyLength ?? 0);
|
|
786
851
|
if (!p.includeArtifacts) delete t.artifacts;
|
|
787
852
|
tasks.push(t);
|
|
788
853
|
}
|
|
789
|
-
|
|
854
|
+
let nextPageToken = "";
|
|
855
|
+
if (found.length > size && page.length) {
|
|
856
|
+
const updatedAt = String(page[page.length - 1].updated_at);
|
|
857
|
+
const id = String(page[page.length - 1].id);
|
|
858
|
+
if (INBOX_STAMP.test(updatedAt) && A.ID_RE.test(id)) nextPageToken = b64url(`${updatedAt}\n${id}`);
|
|
859
|
+
}
|
|
860
|
+
return { tasks, nextPageToken, pageSize: size, totalSize: total?.n ?? tasks.length };
|
|
790
861
|
};
|
|
791
862
|
|
|
792
|
-
const HANDLERS: Record<string, Rpc> = {
|
|
863
|
+
const HANDLERS: Record<string, Rpc> = {
|
|
864
|
+
send: rpcSend, get: rpcGet, cancel: rpcCancel, pushset: rpcPushSet, pushget: rpcPushGet,
|
|
865
|
+
pushlist: rpcPushList, pushdel: rpcPushDel, list: rpcList,
|
|
866
|
+
};
|
|
793
867
|
|
|
794
868
|
/** 401 for A2A calls. Unless pairing is off, the error says how to get a token (device flow), so a generic agent can follow it.
|
|
795
869
|
* The JSON-RPC id is echoed when the request body parses (JSON-RPC 2.0); WWW-Authenticate points to RFC 9728 metadata. */
|
|
@@ -836,7 +910,7 @@ async function handleRpc(req: Request, ctx: TenantContext, ectx: ExecutionContex
|
|
|
836
910
|
const declared = declaredA2AVersion(req);
|
|
837
911
|
if (declared.raw && !declared.version) return err(-32009, `A2A version ${declared.raw} not supported (supported: 0.3, 1.0)`);
|
|
838
912
|
if (EXTENDED_CARD_METHODS.has(method)) return err(-32007, "Extended agent card is not configured");
|
|
839
|
-
const found = rpcOperation(method
|
|
913
|
+
const found = rpcOperation(method);
|
|
840
914
|
if (!found) return err(-32601, "Method not found");
|
|
841
915
|
const version = declared.version || found.version;
|
|
842
916
|
const op = found.op;
|
|
@@ -1088,6 +1162,8 @@ async function handlePush(req: Request, ctx: TenantContext, ectx: ExecutionConte
|
|
|
1088
1162
|
|
|
1089
1163
|
// ------------------------------------------------------------------ owner API (local CLI)
|
|
1090
1164
|
const INBOX_STAMP = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?Z$/;
|
|
1165
|
+
/** Inbound labels and outbound aliases. Used to scope a conversation read to one peer. */
|
|
1166
|
+
const PEER_FILTER = /^[A-Za-z0-9_.-]{1,64}$/;
|
|
1091
1167
|
|
|
1092
1168
|
function inboxLimit(raw: string | null): number {
|
|
1093
1169
|
if (raw === null) return M.INBOX_MAX;
|
|
@@ -1274,6 +1350,8 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1274
1350
|
}
|
|
1275
1351
|
if (seg[0] === "inbox" && m === "GET") {
|
|
1276
1352
|
const contextFilter = url.searchParams.get("context");
|
|
1353
|
+
const fromFilter = url.searchParams.get("from");
|
|
1354
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) throw new HttpError(400, "from is invalid");
|
|
1277
1355
|
const all = url.searchParams.get("all") === "1";
|
|
1278
1356
|
const limit = inboxLimit(url.searchParams.get("limit"));
|
|
1279
1357
|
const cursor = inboxCursor(url.searchParams.get("cursor"));
|
|
@@ -1281,6 +1359,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1281
1359
|
const args: Json[] = [];
|
|
1282
1360
|
if (!all) q += " AND state IN ('submitted','working')";
|
|
1283
1361
|
if (contextFilter) { A.checkId(contextFilter, "contextId"); q += " AND context_id = ?"; args.push(contextFilter); }
|
|
1362
|
+
if (fromFilter) { q += " AND peer = ?"; args.push(fromFilter); }
|
|
1284
1363
|
if (cursor) { q += " AND (updated_at > ? OR (updated_at = ? AND id > ?))"; args.push(cursor.updatedAt, cursor.updatedAt, cursor.id); }
|
|
1285
1364
|
q += " ORDER BY updated_at, id LIMIT ?";
|
|
1286
1365
|
args.push(limit + 1);
|
|
@@ -1302,6 +1381,7 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1302
1381
|
const oargs: Json[] = [];
|
|
1303
1382
|
if (!all) oq += " AND updates_seen = 0";
|
|
1304
1383
|
if (contextFilter) { oq += " AND context_id = ?"; oargs.push(contextFilter); }
|
|
1384
|
+
if (fromFilter) { oq += " AND peer = ?"; oargs.push(fromFilter); }
|
|
1305
1385
|
oq += " ORDER BY sent_at LIMIT ?";
|
|
1306
1386
|
oargs.push(limit);
|
|
1307
1387
|
const replies = await ctx.DB.prepare(oq).bind(...oargs).all();
|
|
@@ -1369,9 +1449,22 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1369
1449
|
}
|
|
1370
1450
|
if (seg[0] === "history" && seg[1] && m === "GET") {
|
|
1371
1451
|
const n = Math.min(Number(url.searchParams.get("n") || "50"), 1000);
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1452
|
+
const fromFilter = url.searchParams.get("from");
|
|
1453
|
+
if (fromFilter !== null && !PEER_FILTER.test(fromFilter)) return json({ error: "from is invalid" }, 400);
|
|
1454
|
+
// Without `from`, rows for every peer that owns this context id (a task or an outbound task), plus local notes.
|
|
1455
|
+
// With `from`, one peer's rows only, so another peer's conversation is not in this read.
|
|
1456
|
+
const rows = await ctx.DB.prepare(fromFilter ? `SELECT * FROM (
|
|
1457
|
+
SELECT * FROM history WHERE context_id = ? AND (
|
|
1458
|
+
peer = ?
|
|
1459
|
+
OR (
|
|
1460
|
+
(peer IS NULL OR peer = '')
|
|
1461
|
+
AND (
|
|
1462
|
+
task_id IN (SELECT id FROM tasks WHERE context_id = ? AND peer = ?)
|
|
1463
|
+
OR task_id IN (SELECT task_id FROM outbound WHERE context_id = ? AND peer = ?)
|
|
1464
|
+
)
|
|
1465
|
+
)
|
|
1466
|
+
) ORDER BY seq DESC LIMIT ?
|
|
1467
|
+
) ORDER BY seq` : `SELECT * FROM (
|
|
1375
1468
|
SELECT * FROM history WHERE context_id = ? AND (
|
|
1376
1469
|
peer IS NULL OR peer = ''
|
|
1377
1470
|
OR peer IN (SELECT peer FROM tasks WHERE context_id = ? AND peer IS NOT NULL AND peer != '')
|
|
@@ -1381,7 +1474,9 @@ async function handleOwner(req: Request, ctx: TenantContext, ectx: ExecutionCont
|
|
|
1381
1474
|
AND NOT EXISTS (SELECT 1 FROM outbound WHERE context_id = ?)
|
|
1382
1475
|
)
|
|
1383
1476
|
) ORDER BY seq DESC LIMIT ?
|
|
1384
|
-
) ORDER BY seq`).bind(
|
|
1477
|
+
) ORDER BY seq`).bind(...(fromFilter
|
|
1478
|
+
? [seg[1], fromFilter, seg[1], fromFilter, seg[1], fromFilter, n]
|
|
1479
|
+
: [seg[1], seg[1], seg[1], seg[1], seg[1], n])).all();
|
|
1385
1480
|
return json((rows.results as Json[]).map((r) => ({ ...r, data: r.data_json ? JSON.parse(r.data_json) : undefined, data_json: undefined })));
|
|
1386
1481
|
}
|
|
1387
1482
|
if (seg[0] === "history" && !seg[1] && m === "POST") {
|
|
@@ -2823,6 +2918,11 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2823
2918
|
case "inbox": {
|
|
2824
2919
|
const qs = new URLSearchParams();
|
|
2825
2920
|
if (a.contextId !== undefined) qs.set("context", A.checkId(argStr(a, "contextId"), "contextId"));
|
|
2921
|
+
if (a.from !== undefined) {
|
|
2922
|
+
const from = argStr(a, "from");
|
|
2923
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2924
|
+
qs.set("from", from);
|
|
2925
|
+
}
|
|
2826
2926
|
if (a.all === true) qs.set("all", "1");
|
|
2827
2927
|
if (a.cursor !== undefined) {
|
|
2828
2928
|
const cursor = argStr(a, "cursor");
|
|
@@ -2850,7 +2950,13 @@ async function runTool(ctx: TenantContext, ectx: ExecutionContext, name: string,
|
|
|
2850
2950
|
case "history": {
|
|
2851
2951
|
const n = a.n === undefined ? 50 : Number(a.n);
|
|
2852
2952
|
if (!Number.isInteger(n) || n < 1 || n > 1000) throw new ToolError("n must be 1-1000");
|
|
2853
|
-
|
|
2953
|
+
let histPath = `/owner/history/${enc(A.checkId(argStr(a, "contextId"), "contextId"))}?n=${n}`;
|
|
2954
|
+
if (a.from !== undefined) {
|
|
2955
|
+
const from = argStr(a, "from");
|
|
2956
|
+
if (!PEER_FILTER.test(from)) throw new ToolError("from is invalid");
|
|
2957
|
+
histPath += `&from=${enc(from)}`;
|
|
2958
|
+
}
|
|
2959
|
+
return M.toolText(`History, oldest first. ${UNTRUSTED}`, must(await ownerCall(ctx, ectx, "GET", histPath)));
|
|
2854
2960
|
}
|
|
2855
2961
|
case "mark_working": {
|
|
2856
2962
|
const r = must(await ownerCall(ctx, ectx, "POST", `/owner/tasks/${enc(A.checkId(argStr(a, "taskId"), "taskId"))}/working`));
|
package/worker/src/mcp.ts
CHANGED
|
@@ -323,6 +323,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
323
323
|
description: `Open tasks other agents sent to this A2A endpoint (state submitted or working), oldest first, one page at a time, plus a reply to a message you sent (kind "outbound") until this inbox lists it, plus pending pairing requests. When the result says more tasks are waiting, call inbox again with the cursor before you stop. Handle each inbound task, then answer it with reply. A reply to a message you sent: read it with history, and continue that conversation with send. ${RULES}`,
|
|
324
324
|
inputSchema: { type: "object", properties: {
|
|
325
325
|
contextId: str("Only this conversation"),
|
|
326
|
+
from: str("Only this peer (the wake's from). The same context id can be a different peer's conversation"),
|
|
326
327
|
all: { type: "boolean", description: "Include finished tasks on this page" },
|
|
327
328
|
cursor: str("Cursor from the previous inbox page, when more tasks are waiting"),
|
|
328
329
|
limit: { type: "integer", minimum: 1, maximum: INBOX_MAX, description: `Tasks in this page (default ${INBOX_PAGE}, at most ${INBOX_MAX})` },
|
|
@@ -332,7 +333,7 @@ export const TOOLS: { name: string; title: string; description: string; inputSch
|
|
|
332
333
|
inputSchema: { type: "object", properties: { taskId: str("Task id from the A2A endpoint") }, required: ["taskId"], additionalProperties: false } },
|
|
333
334
|
{ name: "history", title: "Conversation history", annotations: ro,
|
|
334
335
|
description: `The log of one conversation (both directions), oldest first. ${RULES}`,
|
|
335
|
-
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
|
+
inputSchema: { type: "object", properties: { contextId: str("Conversation (context) id"), from: str("Only this peer (the wake's from)"), n: { type: "integer", minimum: 1, maximum: 1000, description: "Entries (default 50)" } }, required: ["contextId"], additionalProperties: false } },
|
|
336
337
|
{ name: "mark_working", title: "Mark task working", annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
337
338
|
description: "Tell the peer you are working on a task (state working; the peer is notified if it asked for push updates). Use for anything that takes more than a moment.",
|
|
338
339
|
inputSchema: { type: "object", properties: { taskId: str("Task id") }, required: ["taskId"], additionalProperties: false } },
|
package/worker/src/migrations.ts
CHANGED
|
@@ -11,6 +11,7 @@ import m0008 from "../migrations/0008_oidc_txns.sql?raw";
|
|
|
11
11
|
import m0009 from "../migrations/0009_outbound_seen.sql?raw";
|
|
12
12
|
import m0010 from "../migrations/0010_conversation_peer.sql?raw";
|
|
13
13
|
import m0011 from "../migrations/0011_conversation_owner.sql?raw";
|
|
14
|
+
import m0012 from "../migrations/0012_conversation_peer_owner.sql?raw";
|
|
14
15
|
import type { Migration } from "./storage.ts";
|
|
15
16
|
|
|
16
17
|
export const MIGRATIONS: Migration[] = [
|
|
@@ -25,4 +26,5 @@ export const MIGRATIONS: Migration[] = [
|
|
|
25
26
|
{ name: "0009_outbound_seen.sql", sql: m0009 },
|
|
26
27
|
{ name: "0010_conversation_peer.sql", sql: m0010 },
|
|
27
28
|
{ name: "0011_conversation_owner.sql", sql: m0011 },
|
|
29
|
+
{ name: "0012_conversation_peer_owner.sql", sql: m0012 },
|
|
28
30
|
];
|
package/worker/src/owner-rpc.ts
CHANGED
|
@@ -105,25 +105,31 @@ function noQuery(q: Record<string, string>): string | null {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
function inboxQuery(q: Record<string, string>): string | null {
|
|
108
|
-
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor") return null;
|
|
108
|
+
for (const k of Object.keys(q)) if (k !== "context" && k !== "all" && k !== "limit" && k !== "cursor" && k !== "from") return null;
|
|
109
109
|
if (q.context !== undefined && !idOk(q.context)) return null;
|
|
110
110
|
if (q.all !== undefined && q.all !== "1") return null;
|
|
111
111
|
if (q.limit !== undefined && (!/^[1-9]\d{0,2}$/.test(q.limit) || Number(q.limit) > 200)) return null;
|
|
112
112
|
if (q.cursor !== undefined && !/^[A-Za-z0-9_-]{1,256}$/.test(q.cursor)) return null;
|
|
113
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
113
114
|
const sp = new URLSearchParams();
|
|
114
115
|
if (q.context) sp.set("context", q.context);
|
|
115
116
|
if (q.all) sp.set("all", "1");
|
|
116
117
|
if (q.limit) sp.set("limit", q.limit);
|
|
117
118
|
if (q.cursor) sp.set("cursor", q.cursor);
|
|
119
|
+
if (q.from) sp.set("from", q.from);
|
|
118
120
|
const s = sp.toString();
|
|
119
121
|
return s ? `?${s}` : "";
|
|
120
122
|
}
|
|
121
123
|
|
|
122
124
|
function historyQuery(q: Record<string, string>): string | null {
|
|
123
|
-
for (const k of Object.keys(q)) if (k !== "n") return null;
|
|
124
|
-
if (q.n
|
|
125
|
-
if (!/^[
|
|
126
|
-
|
|
125
|
+
for (const k of Object.keys(q)) if (k !== "n" && k !== "from") return null;
|
|
126
|
+
if (q.n !== undefined && (!/^[1-9][0-9]{0,3}$/.test(q.n) || Number(q.n) > 1000)) return null;
|
|
127
|
+
if (q.from !== undefined && !/^[A-Za-z0-9_.-]{1,64}$/.test(q.from)) return null;
|
|
128
|
+
const sp = new URLSearchParams();
|
|
129
|
+
if (q.n) sp.set("n", q.n);
|
|
130
|
+
if (q.from) sp.set("from", q.from);
|
|
131
|
+
const s = sp.toString();
|
|
132
|
+
return s ? `?${s}` : "";
|
|
127
133
|
}
|
|
128
134
|
|
|
129
135
|
/** `format=sql` is the only export query. The RPC still calls the JSON handler and redacts before building SQL. */
|
package/worker/src/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Inbox build. Keep this equal to `version` in cli/package.json. */
|
|
2
|
-
export const VERSION = "0.8.
|
|
2
|
+
export const VERSION = "0.8.18";
|
package/worker/src/wake.ts
CHANGED
|
@@ -92,9 +92,11 @@ export function pairingInstructions(p: PairingInfo, cli: string): string {
|
|
|
92
92
|
|
|
93
93
|
function hintFor(ev: WakeEvent, cli: string): string {
|
|
94
94
|
if (ev.kind === "pairing_request") return `${cli} pair list`;
|
|
95
|
+
// `--from` keeps the read on this peer. The same context id can be another peer's separate conversation.
|
|
96
|
+
const from = /^[A-Za-z0-9_.-]{1,64}$/.test(ev.from) ? ` --from ${ev.from}` : "";
|
|
95
97
|
return ev.kind === "outbound_update"
|
|
96
|
-
? `${cli} history ${ev.contextId}`
|
|
97
|
-
: `${cli} inbox --context ${ev.contextId}`;
|
|
98
|
+
? `${cli} history ${ev.contextId}${from}`
|
|
99
|
+
: `${cli} inbox --context ${ev.contextId}${from}`;
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** Human/agent-readable wake text. `withPreview=false` keeps peer-authored text out entirely. */
|