@nelsonlongxiang/dsh-open-a2a-net 0.6.30 → 0.6.32
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 +4 -1
- package/README_zh.md +3 -0
- package/assets/nexusDist/assets/index-D5H8R8de.js +9 -0
- package/assets/nexusDist/assets/index-D9GYZyGZ.css +1 -0
- package/assets/nexusDist/index.html +2 -2
- package/cli/a2a.mjs +445 -57
- package/lib/a2a-client.js +9 -4
- package/lib/auth-observer.js +254 -0
- package/lib/blob-store.js +210 -0
- package/lib/card.js +4 -2
- package/lib/cli-deposit.js +122 -7
- package/lib/client.js +64 -64
- package/lib/client.js.map +1 -1
- package/lib/faces/admin.js +133 -43
- package/lib/faces/canvas-notify.js +12 -3
- package/lib/faces/files.js +155 -0
- package/lib/faces/nodes.js +171 -0
- package/lib/faces/session-log.js +162 -0
- package/lib/faces/wire.js +206 -39
- package/lib/index.js +809 -1720
- package/lib/joined-store.js +23 -0
- package/lib/ledger-conservation.js +97 -0
- package/lib/legacy/teams-bridge-inbound.js +156 -0
- package/lib/node-bind.js +534 -0
- package/lib/node-github.js +72 -0
- package/lib/node-self.js +453 -0
- package/lib/node-store.js +484 -0
- package/lib/node-tasks.js +342 -0
- package/lib/origin-claim.js +246 -0
- package/lib/peer-directory.js +134 -0
- package/lib/receipt.js +26 -1
- package/lib/skill/SKILL.md +3 -1
- package/lib/skill/references/cli.md +82 -3
- package/lib/skill/references/collaboration.md +146 -73
- package/lib/skill/references/discovery.md +29 -0
- package/lib/skill/references/intake.md +107 -0
- package/lib/skill/references/router.md +96 -0
- package/lib/skill/references/setup.md +2 -1
- package/lib/task-auth.js +483 -0
- package/lib/task-ledger.js +324 -12
- package/lib/team-store.js +24 -1
- package/lib/tools/act.js +383 -0
- package/lib/tools/files.js +201 -0
- package/lib/tools/info.js +684 -0
- package/lib/types/a2a-client.d.ts +10 -4
- package/lib/types/auth-observer.d.ts +110 -0
- package/lib/types/blob-store.d.ts +83 -0
- package/lib/types/cli-deposit.d.ts +38 -0
- package/lib/types/faces/admin.d.ts +14 -2
- package/lib/types/faces/canvas-notify.d.ts +4 -1
- package/lib/types/faces/files.d.ts +53 -0
- package/lib/types/faces/nodes.d.ts +69 -0
- package/lib/types/faces/session-log.d.ts +44 -0
- package/lib/types/faces/wire.d.ts +50 -5
- package/lib/types/index.d.ts +27 -0
- package/lib/types/joined-store.d.ts +9 -0
- package/lib/types/ledger-conservation.d.ts +77 -0
- package/lib/types/legacy/teams-bridge-inbound.d.ts +94 -0
- package/lib/types/node-bind.d.ts +220 -0
- package/lib/types/node-github.d.ts +35 -0
- package/lib/types/node-self.d.ts +178 -0
- package/lib/types/node-store.d.ts +180 -0
- package/lib/types/node-tasks.d.ts +121 -0
- package/lib/types/origin-claim.d.ts +119 -0
- package/lib/types/peer-directory.d.ts +39 -0
- package/lib/types/receipt.d.ts +12 -0
- package/lib/types/task-auth.d.ts +271 -0
- package/lib/types/task-ledger.d.ts +143 -6
- package/lib/types/team-store.d.ts +9 -0
- package/lib/types/tools/act.d.ts +52 -0
- package/lib/types/tools/files.d.ts +38 -0
- package/lib/types/tools/info.d.ts +102 -0
- package/lib/types/types.d.ts +6 -0
- package/nexus-stage/src/canvas-ops.ts +17 -2
- package/nexus-stage/src/canvas-wire.ts +27 -19
- package/nexus-stage/src/federation.ts +1 -0
- package/nexus-stage/src/main.ts +41 -18
- package/nexus-stage/src/planning-member-handles.ts +34 -0
- package/nexus-stage/src/planning-mirror.ts +47 -3
- package/nexus-stage/src/planning-reachability.ts +28 -0
- package/nexus-stage/src/planning-types.ts +1 -1
- package/nexus-stage/src/planning-view.ts +30 -27
- package/nexus-stage/src/planning.css +4 -0
- package/nexus-stage/src/wb-directory.ts +13 -0
- package/nexus-stage/src/workbench.css +3 -0
- package/nexus-stage/src/workbench.ts +5 -1
- package/package.json +1 -1
- package/assets/nexusDist/assets/index-C_x3iAth.js +0 -9
- package/assets/nexusDist/assets/index-Dz8N80tI.css +0 -1
package/lib/tools/act.js
ADDED
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The action-plane model tools (gh#77 B3-W): team_join / team_leave /
|
|
3
|
+
* exposure / report / route-shell moved verbatim out of apply() in
|
|
4
|
+
* src/index.ts. Registration order is preserved by the caller invoking each
|
|
5
|
+
* registrar at the original tool position; routeTrunk stays in index.ts (the
|
|
6
|
+
* quick-dispatch control route shares it) and arrives as a dep, and so do
|
|
7
|
+
* recordTeamAudit and a2aJoinGateRefusal (other faces use them). renderRoute
|
|
8
|
+
* moved with the route shell because only it renders there.
|
|
9
|
+
* @module src/tools/act
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
13
|
+
import { LOCAL_ROUTE, peerContentMeta } from "../node-self.js";
|
|
14
|
+
/** Model-facing text for one route result. */
|
|
15
|
+
function renderRoute(_args, value) {
|
|
16
|
+
const result = value;
|
|
17
|
+
const text = result.ok
|
|
18
|
+
? [
|
|
19
|
+
`Team ${result.team} replied (task ${result.task_id}, status ${result.task_status}, context ${result.context_id}):`,
|
|
20
|
+
result.reply,
|
|
21
|
+
].join('\n')
|
|
22
|
+
: `A2A route failed${result.code === undefined ? '' : ` (code ${String(result.code)})`}: ${result.error}`;
|
|
23
|
+
return [{ type: 'text', text }];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Build the five registrars. Invoke each at the tool's original registration
|
|
27
|
+
* position — the registry order the model sees must not change.
|
|
28
|
+
*/
|
|
29
|
+
export function createActTools(deps) {
|
|
30
|
+
const { ctx, config, logger, sessionNodes, joinedSessions, teamMemberships, exposureStore, reportRing, recordTeamAudit, a2aJoinGateRefusal, id8, routeTrunk, } = deps;
|
|
31
|
+
const reportLastAt = new Map();
|
|
32
|
+
/** Fix ④ loop circuit-breaker: session+text-hash → accepted-report timestamps (10min window). */
|
|
33
|
+
const reportLoopRing = new Map();
|
|
34
|
+
// S4: team-membership tools — node-declared rosters under the owner's
|
|
35
|
+
// allowlist curation. Joining is a network-visible declaration (the card
|
|
36
|
+
// publishes it), so the default allowlist denies everything: a refused
|
|
37
|
+
// join says why and names the curation surface instead of failing
|
|
38
|
+
// silently. Leaving is always allowed — it only shrinks exposure.
|
|
39
|
+
// S4 audit: every join/leave attempt — allowed or refused — lands in
|
|
40
|
+
// a bounded ring the state route serves (the ring itself is declared with
|
|
41
|
+
// the stores: the boot-settlement prune records before this section
|
|
42
|
+
// initializes). Refusals are the audit-worthy half (each one is widening
|
|
43
|
+
// pressure on the allowlist); log lines rotate away, this face is queryable.
|
|
44
|
+
// gh#11 (N7, operator directive: team creation is a human act): only an
|
|
45
|
+
// EXACT allowlist entry authorizes a join — each exact name is a
|
|
46
|
+
// deliberate operator act of creation. Wildcard/prefix patterns match
|
|
47
|
+
// nothing at join time: a pattern pre-authorizing every future name
|
|
48
|
+
// under it would let a model mint teams nobody created. Pattern entries
|
|
49
|
+
// remain readable (whoami shows them) but carry no mint rights.
|
|
50
|
+
const teamJoinExactAllowed = (team) => deps.ownerAllowlist.includes(team);
|
|
51
|
+
const teamJoinPatternMatches = (team) => deps.ownerAllowlist.some(pattern => {
|
|
52
|
+
if (pattern === '*')
|
|
53
|
+
return true;
|
|
54
|
+
if (pattern.endsWith('*')) {
|
|
55
|
+
// Boundary: the wildcard consumes at most the remainder of one
|
|
56
|
+
// '/'-delimited segment — `dsh*` matches `dsh/agent-1`, never
|
|
57
|
+
// `dshield`; `dsh/canvas/*` matches the canvas subtree only.
|
|
58
|
+
const prefix = pattern.slice(0, -1);
|
|
59
|
+
return team.startsWith(prefix) && (prefix.endsWith('/') || (team.length > prefix.length && team[prefix.length] === '/'));
|
|
60
|
+
}
|
|
61
|
+
return team === pattern;
|
|
62
|
+
});
|
|
63
|
+
const joinedNodeOrNone = (id) => joinedSessions.has(id) || sessionNodes.has(id) ? id : undefined;
|
|
64
|
+
function registerTeamJoin() {
|
|
65
|
+
ctx.tools.register(defineTool({
|
|
66
|
+
name: 'a2a_team_join',
|
|
67
|
+
description: 'Declare one of this host\'s joined session nodes a member of a routable team '
|
|
68
|
+
+ '(node-declared roster; the host card publishes it and peers rebuild team rosters as the union of '
|
|
69
|
+
+ 'member declarations). Gated by the owner\'s teamJoinAllowlist — an empty list denies every join; '
|
|
70
|
+
+ 'patterns are exact names or trailing-`*` prefixes. Canvas teams are out of scope here: their '
|
|
71
|
+
+ 'membership is assigned by hand in the A2A workbench, and the tool refuses `/canvas/` names. '
|
|
72
|
+
+ 'Collaboration itself stays within teams (team-scoped routing is the separate teamScopeRouting switch).',
|
|
73
|
+
parameters: {
|
|
74
|
+
team: { type: 'string', description: 'The routable team to join (full form, e.g. dsh/review-gate).' },
|
|
75
|
+
id: { type: 'string', description: 'The joined session node id on this host that declares the membership.' },
|
|
76
|
+
},
|
|
77
|
+
output: {
|
|
78
|
+
schema: {
|
|
79
|
+
type: 'object',
|
|
80
|
+
additionalProperties: false,
|
|
81
|
+
properties: {
|
|
82
|
+
ok: { type: 'boolean', required: true },
|
|
83
|
+
team: { type: 'string' },
|
|
84
|
+
id: { type: 'string' },
|
|
85
|
+
teams: { type: 'array', items: { type: 'string' } },
|
|
86
|
+
error: { type: 'string', description: 'Refusal reason when ok is false: unknown node or team not allowlisted.' },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
render: (_args, value) => [{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: value.ok === true
|
|
92
|
+
? `Node ${String(value.id)} joined team ${String(value.team)}; its teams: ${Array.isArray(value.teams) ? value.teams.join(', ') : String(value.team)}`
|
|
93
|
+
: `Join refused: ${String(value.error)}`,
|
|
94
|
+
}],
|
|
95
|
+
},
|
|
96
|
+
presentCall: () => ({ card: 'generic', title: 'A2A team join', kind: 'other', rawInput: null }),
|
|
97
|
+
execute: async (args, exec) => {
|
|
98
|
+
a2aJoinGateRefusal(exec);
|
|
99
|
+
const team = String(args.team ?? '').trim();
|
|
100
|
+
const id = String(args.id ?? '').trim();
|
|
101
|
+
if (team === '' || id === '') {
|
|
102
|
+
const reason = '[BAD_ARGUMENTS] both team and id are required';
|
|
103
|
+
recordTeamAudit('join', team, id, false, reason);
|
|
104
|
+
return { ok: false, error: reason };
|
|
105
|
+
}
|
|
106
|
+
if (joinedNodeOrNone(id) === undefined) {
|
|
107
|
+
// K3 feedback (2026-10-20): reason code + a step the MODEL can take.
|
|
108
|
+
// The old text pointed at a sidebar control and a CLI — neither is
|
|
109
|
+
// executable by a session agent. Joining is a host-side human
|
|
110
|
+
// control; the model's executable step is to ask its operator and
|
|
111
|
+
// to self-check state with a2a_whoami.
|
|
112
|
+
const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — network joining is a host-side human control (the GUI sidebar A2A panel), so relay the request to your human operator; once the node is joined, retry this declaration. Your current state (joined/live, teams, allowlist) is one a2a_whoami call away`;
|
|
113
|
+
recordTeamAudit('join', team, id, false, reason);
|
|
114
|
+
return { ok: false, error: reason };
|
|
115
|
+
}
|
|
116
|
+
// V4 (gh#7, N2): a model tool changes membership for the CALLING
|
|
117
|
+
// session only — agent-1 declaring agent-2 into a team was an agent
|
|
118
|
+
// creating a link for another node, published to the whole fleet via
|
|
119
|
+
// the card. The host face (agent-less, operator surface) may still
|
|
120
|
+
// act for any joined node.
|
|
121
|
+
if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
|
|
122
|
+
const reason = `[NOT_SELF] a model tool may only declare membership for its own session node (${String(exec.agent.id)}) — membership for other nodes is your operator's gesture (the workbench or the host face); N2: agents use links, they do not create them for others`;
|
|
123
|
+
recordTeamAudit('join', team, id, false, reason);
|
|
124
|
+
return { ok: false, error: reason };
|
|
125
|
+
}
|
|
126
|
+
// Unified teaming (0.6.6): canvas teams are the human-composed surface.
|
|
127
|
+
// A session may not self-join one through the roster tool — membership
|
|
128
|
+
// there is assigned by hand, so the refusal names the surface where the
|
|
129
|
+
// assignment happens. Any `/canvas/` name (this zone's or a peer's) is
|
|
130
|
+
// refused: remote canvas membership is the owning host's store, and a
|
|
131
|
+
// roster declaration here would neither feed it nor be read by it.
|
|
132
|
+
if (team.includes('/canvas/')) {
|
|
133
|
+
const reason = `[CANVAS_TEAM] team "${team}" is a canvas team — canvas membership is assigned by hand in the A2A workbench (open /__dsh_a2a_nexus/, pick the team, add or remove members there); the roster tool manages allowlisted teams only. If this membership must change now, relay the request to your human operator`;
|
|
134
|
+
recordTeamAudit('join', team, id, false, reason);
|
|
135
|
+
return { ok: false, error: reason };
|
|
136
|
+
}
|
|
137
|
+
if (!teamJoinExactAllowed(team)) {
|
|
138
|
+
const reason = teamJoinPatternMatches(team)
|
|
139
|
+
? `[PATTERN_NO_MINT] team "${team}" matches only a wildcard/pattern entry of this host's teamJoinAllowlist — patterns carry no mint rights (gh#11/N7: team creation is a human act); ask the owner to add the EXACT team name, which is the act of creating it`
|
|
140
|
+
: `[ALLOWLIST_DENY] team "${team}" is not in this host's teamJoinAllowlist — the owner curates joinable teams with exact names (patterns match nothing at join time; human-composed groups live in the workbench's canvas teams); ask them to add the exact name. The live list is visible in a2a_whoami`;
|
|
141
|
+
recordTeamAudit('join', team, id, false, reason);
|
|
142
|
+
return { ok: false, error: reason };
|
|
143
|
+
}
|
|
144
|
+
teamMemberships.add(id, team);
|
|
145
|
+
recordTeamAudit('join', team, id, true);
|
|
146
|
+
logger.info(`a2a: ${id8(id)} declared membership in ${team}`);
|
|
147
|
+
return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
|
|
148
|
+
},
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
function registerTeamLeave() {
|
|
152
|
+
ctx.tools.register(defineTool({
|
|
153
|
+
name: 'a2a_team_leave',
|
|
154
|
+
description: 'Retract one team-membership declaration previously made by a2a_team_join. Always allowed — leaving only '
|
|
155
|
+
+ 'shrinks the node\'s visible collaboration surface.',
|
|
156
|
+
parameters: {
|
|
157
|
+
team: { type: 'string', description: 'The routable team to leave.' },
|
|
158
|
+
id: { type: 'string', description: 'The session node id retracting the membership.' },
|
|
159
|
+
},
|
|
160
|
+
output: {
|
|
161
|
+
schema: {
|
|
162
|
+
type: 'object',
|
|
163
|
+
additionalProperties: false,
|
|
164
|
+
properties: {
|
|
165
|
+
ok: { type: 'boolean', required: true },
|
|
166
|
+
team: { type: 'string' },
|
|
167
|
+
id: { type: 'string' },
|
|
168
|
+
teams: { type: 'array', items: { type: 'string' } },
|
|
169
|
+
error: { type: 'string', description: 'Refusal reason when ok is false: node unknown or membership absent.' },
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
render: (_args, value) => [{
|
|
173
|
+
type: 'text',
|
|
174
|
+
text: value.ok === true
|
|
175
|
+
? `Node ${String(value.id)} left team ${String(value.team)}; remaining teams: ${Array.isArray(value.teams) && value.teams.length > 0 ? value.teams.join(', ') : 'none'}`
|
|
176
|
+
: `Leave refused: ${String(value.error)}`,
|
|
177
|
+
}],
|
|
178
|
+
},
|
|
179
|
+
presentCall: () => ({ card: 'generic', title: 'A2A team leave', kind: 'other', rawInput: null }),
|
|
180
|
+
execute: async (args, exec) => {
|
|
181
|
+
a2aJoinGateRefusal(exec);
|
|
182
|
+
const team = String(args.team ?? '').trim();
|
|
183
|
+
const id = String(args.id ?? '').trim();
|
|
184
|
+
if (team === '' || id === '') {
|
|
185
|
+
recordTeamAudit('leave', team, id, false, 'both team and id are required');
|
|
186
|
+
return { ok: false, error: 'both team and id are required' };
|
|
187
|
+
}
|
|
188
|
+
if (joinedNodeOrNone(id) === undefined) {
|
|
189
|
+
const reason = `[NOT_JOINED] session ${id} is not a joined node on this host — check current state with a2a_whoami`;
|
|
190
|
+
recordTeamAudit('leave', team, id, false, reason);
|
|
191
|
+
return { ok: false, error: reason };
|
|
192
|
+
}
|
|
193
|
+
// V4 (gh#7, N2): same self-only rule as join — narrowing another
|
|
194
|
+
// node's surface is still a link change by a third party.
|
|
195
|
+
if (exec?.agent !== undefined && String(exec.agent.id) !== id) {
|
|
196
|
+
const reason = `[NOT_SELF] a model tool may only retract membership for its own session node (${String(exec.agent.id)}); N2 — use links, do not reshape another node's`;
|
|
197
|
+
recordTeamAudit('leave', team, id, false, reason);
|
|
198
|
+
return { ok: false, error: reason };
|
|
199
|
+
}
|
|
200
|
+
if (!teamMemberships.teamsOf(id).includes(team)) {
|
|
201
|
+
const reason = `session ${id} declares no membership in "${team}" — nothing to leave`;
|
|
202
|
+
recordTeamAudit('leave', team, id, false, reason);
|
|
203
|
+
return { ok: false, error: reason };
|
|
204
|
+
}
|
|
205
|
+
teamMemberships.remove(id, team);
|
|
206
|
+
recordTeamAudit('leave', team, id, true);
|
|
207
|
+
logger.info(`a2a: ${id8(id)} retracted membership in ${team}`);
|
|
208
|
+
return { ok: true, team, id, teams: [...teamMemberships.teamsOf(id)] };
|
|
209
|
+
},
|
|
210
|
+
}));
|
|
211
|
+
}
|
|
212
|
+
function registerExposure() {
|
|
213
|
+
ctx.tools.register(defineTool({
|
|
214
|
+
name: 'a2a_exposure',
|
|
215
|
+
description: '#19 exposure tiers: set THIS session\'s network visibility. \'linked\' (the default '
|
|
216
|
+
+ 'floor) — visible to linked callers per the card-face link gate; \'fleet\' — a voluntary '
|
|
217
|
+
+ 'opt-UP making the session\'s team visible to any fleet peer. Members may raise and lower '
|
|
218
|
+
+ 'their own tier freely; nothing exists below linked (no private tier in v1), and group '
|
|
219
|
+
+ 'visibility always derives from the strictest member (no group knobs). Requires the '
|
|
220
|
+
+ 'session to be joined to the network.',
|
|
221
|
+
parameters: {
|
|
222
|
+
tier: { type: 'string', enum: ['linked', 'fleet'], required: true, description: 'The exposure tier to set for this session.' },
|
|
223
|
+
},
|
|
224
|
+
output: {
|
|
225
|
+
schema: {
|
|
226
|
+
type: 'object',
|
|
227
|
+
additionalProperties: false,
|
|
228
|
+
properties: {
|
|
229
|
+
ok: { type: 'boolean', required: true },
|
|
230
|
+
id: { type: 'string' },
|
|
231
|
+
tier: { type: 'string' },
|
|
232
|
+
error: { type: 'string', description: 'Refusal reason when ok is false (not joined, or no agent context).' },
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
render: (_args, value) => [{
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: value.ok === true
|
|
238
|
+
? `Session ${String(value.id)} exposure tier set to ${String(value.tier)}`
|
|
239
|
+
: `Exposure refused: ${String(value.error)}`,
|
|
240
|
+
}],
|
|
241
|
+
},
|
|
242
|
+
presentCall: () => ({ card: 'generic', title: 'A2A exposure tier', kind: 'other', rawInput: null }),
|
|
243
|
+
execute: async (args, exec) => {
|
|
244
|
+
const agent = exec.agent;
|
|
245
|
+
if (agent === undefined) {
|
|
246
|
+
return { ok: false, error: 'no agent context — the exposure tier is a per-session fact; call from a session' };
|
|
247
|
+
}
|
|
248
|
+
const id = String(agent.id);
|
|
249
|
+
if (!joinedSessions.has(id)) {
|
|
250
|
+
return { ok: false, error: 'session is not joined to the A2A network — join first (the sidebar control or the host join route)' };
|
|
251
|
+
}
|
|
252
|
+
const tier = args.tier === 'fleet' ? 'fleet' : 'linked';
|
|
253
|
+
exposureStore.setTier(id, tier);
|
|
254
|
+
logger.info(`a2a: ${id8(id)} exposure tier -> ${tier}`);
|
|
255
|
+
return { ok: true, id, tier };
|
|
256
|
+
},
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
function registerReport() {
|
|
260
|
+
ctx.tools.register(defineTool({
|
|
261
|
+
name: 'a2a_report',
|
|
262
|
+
description: 'Report a task or status to this host\'s A2A network — the ONE ingress a session WITHOUT network '
|
|
263
|
+
+ 'membership may use (deliberately exempt from the join gate: one-way reporting grants no presence, '
|
|
264
|
+
+ 'no discovery, no routing rights; the report lands in the state face\'s reports ring for '
|
|
265
|
+
+ 'supervision to read and act on, e.g. adopt or re-join the reporter). Use when you are off the '
|
|
266
|
+
+ 'mesh and need to say so. Rate limited to one report per 30s per session; messages cap at 4000 chars.',
|
|
267
|
+
parameters: {
|
|
268
|
+
message: { type: 'string', required: true, description: 'The report text (≤4000 chars).' },
|
|
269
|
+
level: { type: 'string', enum: ['info', 'warning', 'error'], description: 'Severity; error also warns the host log.' },
|
|
270
|
+
task: { type: 'string', description: 'Optional task reference (id/link).' },
|
|
271
|
+
},
|
|
272
|
+
output: {
|
|
273
|
+
schema: {
|
|
274
|
+
type: 'object',
|
|
275
|
+
additionalProperties: false,
|
|
276
|
+
properties: {
|
|
277
|
+
ok: { type: 'boolean', required: true },
|
|
278
|
+
reportId: { type: 'string' },
|
|
279
|
+
error: { type: 'string', description: 'Refusal reason when ok is false (empty message, over-cap, or rate limited).' },
|
|
280
|
+
},
|
|
281
|
+
},
|
|
282
|
+
render: (_args, value) => [{
|
|
283
|
+
type: 'text',
|
|
284
|
+
text: value.ok === true ? `Report registered (${String(value.reportId)})` : `Report refused: ${String(value.error)}`,
|
|
285
|
+
}],
|
|
286
|
+
},
|
|
287
|
+
presentCall: () => ({ card: 'generic', title: 'A2A report', kind: 'other', rawInput: null }),
|
|
288
|
+
execute: async (args, exec) => {
|
|
289
|
+
// Deliberately NO a2aJoinGateRefusal: reporting is the one thing an
|
|
290
|
+
// unjoined session may do — that is the tool's entire purpose.
|
|
291
|
+
const message = String(args.message ?? '').trim();
|
|
292
|
+
if (message === '')
|
|
293
|
+
return { ok: false, error: 'message is required' };
|
|
294
|
+
if (message.length > 4000)
|
|
295
|
+
return { ok: false, error: 'message exceeds the 4000-char cap' };
|
|
296
|
+
const session = exec?.agent !== undefined ? String(exec.agent.id) : 'host';
|
|
297
|
+
const now = Date.now();
|
|
298
|
+
const last = reportLastAt.get(session) ?? 0;
|
|
299
|
+
if (now - last < 30_000)
|
|
300
|
+
return { ok: false, error: 'rate limited: one report per 30s per session' };
|
|
301
|
+
// Fix ④ (loop circuit-breaker, d44efa9f pattern): the SAME session
|
|
302
|
+
// reporting the SAME text ≥3 times inside a 10-minute window is a
|
|
303
|
+
// loop, not a situation — refuse with a named reason and audit the
|
|
304
|
+
// ring (the first copies go through; the bounded ring self-expires).
|
|
305
|
+
const loopKey = `${session}:${createHash('sha256').update(message).digest('hex').slice(0, 16)}`;
|
|
306
|
+
const loopWindow = (reportLoopRing.get(loopKey) ?? []).filter(at => now - at < 600_000);
|
|
307
|
+
if (loopWindow.length >= 3) {
|
|
308
|
+
recordTeamAudit('agent-less', 'a2a_report', session, false, 'loop detected: 3 identical reports in 10min');
|
|
309
|
+
return { ok: false, error: '循环检测:同会话同文 10 分钟内已上报 3 次,本 report 熔断拒绝。若确需再报请变更内容或等窗口滑出' };
|
|
310
|
+
}
|
|
311
|
+
loopWindow.push(now);
|
|
312
|
+
reportLoopRing.set(loopKey, loopWindow);
|
|
313
|
+
reportLastAt.set(session, now);
|
|
314
|
+
const level = args.level === 'error' || args.level === 'warning' ? args.level : 'info';
|
|
315
|
+
const reportId = `report-${Math.random().toString(16).slice(2, 10)}`;
|
|
316
|
+
reportRing.push({
|
|
317
|
+
at: now,
|
|
318
|
+
level,
|
|
319
|
+
message: message.slice(0, 4000),
|
|
320
|
+
session,
|
|
321
|
+
...(typeof args.task === 'string' && args.task.trim() !== '' ? { task: args.task.trim() } : {}),
|
|
322
|
+
});
|
|
323
|
+
if (reportRing.length > 64)
|
|
324
|
+
reportRing.splice(0, reportRing.length - 64);
|
|
325
|
+
if (level === 'error')
|
|
326
|
+
logger.warn(`a2a report (${id8(session)}): ${message.slice(0, 200)}`);
|
|
327
|
+
return { ok: true, reportId };
|
|
328
|
+
},
|
|
329
|
+
}));
|
|
330
|
+
}
|
|
331
|
+
function registerRoute() {
|
|
332
|
+
ctx.tools.register(defineTool({
|
|
333
|
+
name: 'a2a_route',
|
|
334
|
+
description: 'Route a message to a remote A2A team on the peer network and await its reply. '
|
|
335
|
+
+ 'Pick the team with a2a_teams first. Team names also resolve through peers\' signed zone '
|
|
336
|
+
+ 'delegation records (zone-relative, at most 5 hops). Reuse context_id from a previous reply '
|
|
337
|
+
+ 'to continue that conversation. The call blocks until the remote team responds, which may take minutes — '
|
|
338
|
+
+ 'pass async for long-running tasks: delivery returns immediately and the target routes a receipt '
|
|
339
|
+
+ '(message starting "[A2A receipt] task <task_id> <outcome summary>") back to your team.',
|
|
340
|
+
parameters: {
|
|
341
|
+
team: { type: 'string', required: true, description: 'Target team name, from a2a_teams.' },
|
|
342
|
+
message: { type: 'string', required: true, description: 'The request text to send. For long verdict-style payloads, split into parts and label each "1/N", "2/N"… so the receiver can reassemble.' },
|
|
343
|
+
context_id: { type: 'string', description: 'Context id from a previous a2a_route reply, to continue that conversation.' },
|
|
344
|
+
async: { type: 'boolean', description: 'Fire-and-forget: deliver and return immediately (delivered:true + task_id) without waiting for the target\'s reply. The target answers by routing a receipt — a message starting "[A2A receipt] task <task_id> <outcome summary>" — back to your team (visible in a2a_status activity), or follow up with the context_id. Prefer async for long-running tasks (minutes to hours).' },
|
|
345
|
+
},
|
|
346
|
+
output: {
|
|
347
|
+
// gh#198: the host's own verdict on whether this result came from outside this host, persisted on tool/result.
|
|
348
|
+
presentationMeta: peerContentMeta('a2a_route'),
|
|
349
|
+
schema: {
|
|
350
|
+
type: 'object',
|
|
351
|
+
additionalProperties: true,
|
|
352
|
+
},
|
|
353
|
+
render: renderRoute,
|
|
354
|
+
},
|
|
355
|
+
timeoutMs: config.routeTimeoutMs,
|
|
356
|
+
presentCall: args => ({ card: 'generic', title: `A2A route → ${args.team}`, kind: 'other', rawInput: args }),
|
|
357
|
+
execute: async (args, exec) => {
|
|
358
|
+
// Owner ruling (tool-layer interception): the PROCESS face (bare
|
|
359
|
+
// zone === config.team) keeps its host-level EXEMPTION on the network
|
|
360
|
+
// admission layer — receipts, quick dispatch and host-side routing
|
|
361
|
+
// still target it internally — but a MODEL TOOL call must not address
|
|
362
|
+
// it: the machine's own mailbox is not a member face, and letting a
|
|
363
|
+
// session route there would turn the host entrance into a group-free
|
|
364
|
+
// hop. Host-face entry (agent-less control routes, receipt ladders)
|
|
365
|
+
// never passes through this tool and stays untouched.
|
|
366
|
+
const requestedTeam = String(args.team ?? '').trim();
|
|
367
|
+
if (exec.agent !== undefined && requestedTeam === config.team) {
|
|
368
|
+
return {
|
|
369
|
+
peer_content: false,
|
|
370
|
+
ok: false,
|
|
371
|
+
error: `the process face "${config.team}" is the host's own mailbox — model tools must target a member face. Route to a session team handle ("${config.team}/<id8>" from a2a_teams) or a canvas team; receipts and quick dispatch reach the host face on their own paths.`,
|
|
372
|
+
code: -32000,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
// gh#198: the host's own flag, written last so a peer's reply fields cannot set it — a result
|
|
376
|
+
// routeTrunk built on this host alone carries the LOCAL_ROUTE symbol; everything else reached a peer.
|
|
377
|
+
const { [LOCAL_ROUTE]: local, ...result } = await routeTrunk(args, exec.agent, exec.signal);
|
|
378
|
+
return { ...result, peer_content: local !== true };
|
|
379
|
+
},
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
return { registerTeamJoin, registerTeamLeave, registerExposure, registerReport, registerRoute };
|
|
383
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #91 file channel, slice F2 (docs/design/file-channel.md §3.1–§3.3, §3.5–§3.6): the two model
|
|
3
|
+
* tools over the blob store and the pull face.
|
|
4
|
+
*
|
|
5
|
+
* - `a2a_file_send { paths, team, note? }` reads one or more files from the session's workspace,
|
|
6
|
+
* stages each (by sha256, granted to `team`), and routes ONE message carrying one reference line
|
|
7
|
+
* per file through the ordinary route trunk — admission, ledger and receipts are a2a_route's. The
|
|
8
|
+
* route's host-written `peer_content` flag rides the result (N4: a route that reached a peer taints).
|
|
9
|
+
* - `a2a_file_fetch { ref }` pulls one referenced file — from this host's own store when the
|
|
10
|
+
* reference names this host, else over the pull face with this host's OriginClaim envelope — checks
|
|
11
|
+
* sha256 and size, and lands it at `<workspace>/a2a-inbox/<sha12>-<name>`, never following a link.
|
|
12
|
+
* Its result always counts as content from outside this host (N4).
|
|
13
|
+
* @module @nelsonlongxiang/dsh-open-a2a-net/tools/files
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
18
|
+
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
19
|
+
import { BLOB_FILE_MAX, formatFileRef, parseFileRef, sanitizeBlobName } from "../blob-store.js";
|
|
20
|
+
import { grantAdmits } from "../faces/files.js";
|
|
21
|
+
import { LOCAL_ROUTE } from "../node-self.js";
|
|
22
|
+
import { originOf } from "../origin-claim.js";
|
|
23
|
+
/** Files one send may carry (one message; the FloodGuard per-transfer figure). */
|
|
24
|
+
export const FILE_SEND_MAX = 16;
|
|
25
|
+
/** The inbox directory under a session's workspace. */
|
|
26
|
+
export const FILE_INBOX_DIR = 'a2a-inbox';
|
|
27
|
+
/** The session's workspace directory, or '' when it has none. */
|
|
28
|
+
const workspaceOf = (agent) => {
|
|
29
|
+
const cwd = agent?.session?.header?.cwd;
|
|
30
|
+
return typeof cwd === 'string' ? cwd : '';
|
|
31
|
+
};
|
|
32
|
+
export function createFileTools(d) {
|
|
33
|
+
const { ctx, config, blobStore } = d;
|
|
34
|
+
function registerFileSend() {
|
|
35
|
+
ctx.tools.register(defineTool({
|
|
36
|
+
name: 'a2a_file_send',
|
|
37
|
+
description: 'Send one or more files to an A2A team WITHOUT putting their bytes in a session message (#91 file channel). '
|
|
38
|
+
+ 'Each file is stored on this host by sha256 and granted to the team for 24h; the team receives ONE message '
|
|
39
|
+
+ 'with one "[A2A file] v1 …" reference line per file and pulls them with a2a_file_fetch. At most 16 files, '
|
|
40
|
+
+ '64 MiB each. Paths are relative to your workspace (or absolute).',
|
|
41
|
+
parameters: {
|
|
42
|
+
paths: { type: 'array', items: { type: 'string' }, required: true, description: 'Files to send (1–16).' },
|
|
43
|
+
team: { type: 'string', required: true, description: 'Target team, as for a2a_route.' },
|
|
44
|
+
note: { type: 'string', description: 'Optional text to put above the references.' },
|
|
45
|
+
},
|
|
46
|
+
output: {
|
|
47
|
+
schema: { type: 'object', additionalProperties: true },
|
|
48
|
+
render: (_args, value) => [{ type: 'text', text: value.ok === true ? `Sent ${String(value.files.length)} file(s) to ${String(value.team)} (task ${String(value.task_id)}).` : `File send refused: ${String(value.error)}` }],
|
|
49
|
+
},
|
|
50
|
+
presentCall: args => ({ card: 'generic', title: `A2A file send → ${String(args.team)}`, kind: 'other', rawInput: args }),
|
|
51
|
+
execute: async (args, exec) => {
|
|
52
|
+
const agent = exec.agent;
|
|
53
|
+
const team = String(args.team ?? '').trim();
|
|
54
|
+
const paths = Array.isArray(args.paths) ? args.paths.map(p => String(p)) : [];
|
|
55
|
+
if (team === '')
|
|
56
|
+
return { ok: false, error: 'team is required', peer_content: false };
|
|
57
|
+
if (paths.length === 0 || paths.length > FILE_SEND_MAX)
|
|
58
|
+
return { ok: false, error: `paths must name 1–${String(FILE_SEND_MAX)} files`, peer_content: false };
|
|
59
|
+
const from = originOf(d.ownCardUrl());
|
|
60
|
+
if (from === '')
|
|
61
|
+
return { ok: false, error: 'this host publishes no card URL yet (announce is off or the web server has not settled): a receiver could not pull from it', peer_content: false };
|
|
62
|
+
const cwd = workspaceOf(agent);
|
|
63
|
+
const by = agent !== undefined ? String(agent.id) : 'host';
|
|
64
|
+
const staged = [];
|
|
65
|
+
for (const p of paths) {
|
|
66
|
+
const abs = isAbsolute(p) ? p : cwd !== '' ? resolve(cwd, p) : '';
|
|
67
|
+
if (abs === '')
|
|
68
|
+
return { ok: false, error: `relative path ${p} needs a session workspace`, peer_content: false };
|
|
69
|
+
let bytes;
|
|
70
|
+
try {
|
|
71
|
+
const st = statSync(abs);
|
|
72
|
+
if (!st.isFile())
|
|
73
|
+
return { ok: false, error: `${p} is not a file`, peer_content: false };
|
|
74
|
+
if (st.size > BLOB_FILE_MAX)
|
|
75
|
+
return { ok: false, error: `${p} is ${String(st.size)} bytes; the file channel takes at most ${String(BLOB_FILE_MAX)}`, peer_content: false };
|
|
76
|
+
bytes = readFileSync(abs);
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
return { ok: false, error: `cannot read ${p}: ${error instanceof Error ? error.message : String(error)}`, peer_content: false };
|
|
80
|
+
}
|
|
81
|
+
const out = blobStore.stage(bytes, { name: abs.split(/[\\/]/).pop() ?? p, to: team, by });
|
|
82
|
+
if (!out.ok)
|
|
83
|
+
return { ok: false, error: `${p}: ${out.error}`, code: out.code, peer_content: false };
|
|
84
|
+
staged.push({ sha256: out.sha256, size: out.size, name: out.name, expiresAt: out.expiresAt });
|
|
85
|
+
}
|
|
86
|
+
const note = typeof args.note === 'string' ? args.note.trim() : '';
|
|
87
|
+
const message = [...(note !== '' ? [note] : []), ...staged.map(f => formatFileRef({ ...f, from }))].join('\n');
|
|
88
|
+
// The route's own trunk: join gate, admission, ledger and receipts are a2a_route's.
|
|
89
|
+
const { [LOCAL_ROUTE]: local, ...routed } = await d.routeTrunk({ team, message, async: true }, agent, exec.signal);
|
|
90
|
+
return { ...routed, ok: routed.ok === true, team, files: staged, peer_content: local !== true };
|
|
91
|
+
},
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
function registerFileFetch() {
|
|
95
|
+
ctx.tools.register(defineTool({
|
|
96
|
+
name: 'a2a_file_fetch',
|
|
97
|
+
description: 'Pull a file another node sent you with a2a_file_send: pass the whole "[A2A file] v1 …" reference line. '
|
|
98
|
+
+ 'The file is checked against its sha256 and size and saved under a2a-inbox/ in your workspace; the result '
|
|
99
|
+
+ 'gives its path. Fetched content comes from outside this host.',
|
|
100
|
+
parameters: {
|
|
101
|
+
ref: { type: 'string', required: true, description: 'The reference line, as received.' },
|
|
102
|
+
},
|
|
103
|
+
output: {
|
|
104
|
+
schema: { type: 'object', additionalProperties: true },
|
|
105
|
+
render: (_args, value) => [{ type: 'text', text: value.ok === true ? `Fetched ${String(value.path)} (${String(value.size)} bytes, sha256 ${String(value.sha256)}).` : `File fetch failed: ${String(value.error)}` }],
|
|
106
|
+
},
|
|
107
|
+
presentCall: () => ({ card: 'generic', title: 'A2A file fetch', kind: 'other', rawInput: null }),
|
|
108
|
+
execute: async (args, exec) => {
|
|
109
|
+
const agent = exec.agent;
|
|
110
|
+
d.a2aJoinGateRefusal(agent === undefined ? undefined : { agent });
|
|
111
|
+
const ref = parseFileRef(String(args.ref ?? ''));
|
|
112
|
+
if (ref === undefined)
|
|
113
|
+
return { ok: false, code: 'bad-ref', error: 'not an "[A2A file] v1 sha256=… size=… name=… from=<origin>" reference' };
|
|
114
|
+
const cwd = workspaceOf(agent);
|
|
115
|
+
if (cwd === '')
|
|
116
|
+
return { ok: false, code: 'no-workspace', error: 'this session has no workspace to land the file in' };
|
|
117
|
+
const caller = agent !== undefined ? d.sessionTeamOf(agent) : config.team;
|
|
118
|
+
let bytes;
|
|
119
|
+
if (ref.from === originOf(d.ownCardUrl())) {
|
|
120
|
+
// This host's own store: the grant must name THIS session (or a canvas team of this host it is a
|
|
121
|
+
// member of) — the session is its own signer here, never the whole host zone.
|
|
122
|
+
const grant = blobStore.list().find(g => g.sha256 === ref.sha256 && grantAdmits(g.to, caller, caller, config.team, d.canvasMembersOf));
|
|
123
|
+
const local = grant === undefined ? undefined : blobStore.read(grant);
|
|
124
|
+
if (local === undefined)
|
|
125
|
+
return { ok: false, code: 'not-granted', error: 'no live grant lets this session pull that file' };
|
|
126
|
+
bytes = local;
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
const target = `${ref.from}/a2a/blob`;
|
|
130
|
+
let response;
|
|
131
|
+
try {
|
|
132
|
+
response = await d.peerFetch(`${target}?sha256=${ref.sha256}&caller_session=${encodeURIComponent(caller)}`, {
|
|
133
|
+
headers: { ...d.originClaimHeaders({ url: target, method: 'GET' }), ...(config.apiKey !== '' ? { 'X-API-Key': config.apiKey } : {}) },
|
|
134
|
+
redirect: 'manual',
|
|
135
|
+
signal: AbortSignal.any([exec.signal, AbortSignal.timeout(120_000)]),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
140
|
+
return { ok: false, code: why.startsWith('isolated') ? 'isolated' : 'file-origin-unreachable', error: `cannot reach ${ref.from}: ${why}` };
|
|
141
|
+
}
|
|
142
|
+
if (response.status !== 200) {
|
|
143
|
+
const body = await response.json().catch(() => ({}));
|
|
144
|
+
return { ok: false, code: typeof body.code === 'string' ? body.code : `http-${String(response.status)}`, error: `${ref.from} refused: ${String(body.error ?? `HTTP ${String(response.status)}`)}` };
|
|
145
|
+
}
|
|
146
|
+
const declared = Number(response.headers.get('content-length') ?? NaN);
|
|
147
|
+
if (Number.isFinite(declared) && declared !== ref.size) {
|
|
148
|
+
await response.body?.cancel();
|
|
149
|
+
return { ok: false, code: 'size-mismatch', error: `the reference says ${String(ref.size)} bytes; ${ref.from} sends ${String(declared)}` };
|
|
150
|
+
}
|
|
151
|
+
// Bounded read: a peer that streams past the referenced size is cut off, never buffered.
|
|
152
|
+
const reader = response.body?.getReader();
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let got = 0;
|
|
155
|
+
for (;;) {
|
|
156
|
+
const step = reader === undefined ? { done: true, value: undefined } : await reader.read();
|
|
157
|
+
if (step.done)
|
|
158
|
+
break;
|
|
159
|
+
got += step.value.byteLength;
|
|
160
|
+
if (got > ref.size) {
|
|
161
|
+
await reader?.cancel();
|
|
162
|
+
return { ok: false, code: 'size-mismatch', error: `${ref.from} sent more than the referenced ${String(ref.size)} bytes` };
|
|
163
|
+
}
|
|
164
|
+
chunks.push(step.value);
|
|
165
|
+
}
|
|
166
|
+
bytes = Buffer.concat(chunks);
|
|
167
|
+
}
|
|
168
|
+
if (bytes.byteLength !== ref.size)
|
|
169
|
+
return { ok: false, code: 'size-mismatch', error: `expected ${String(ref.size)} bytes, got ${String(bytes.byteLength)}` };
|
|
170
|
+
if (createHash('sha256').update(bytes).digest('hex') !== ref.sha256)
|
|
171
|
+
return { ok: false, code: 'digest-mismatch', error: 'the bytes do not hash to the referenced sha256; nothing was saved' };
|
|
172
|
+
const dir = join(cwd, FILE_INBOX_DIR);
|
|
173
|
+
try {
|
|
174
|
+
if (existsSync(dir) && lstatSync(dir).isSymbolicLink())
|
|
175
|
+
return { ok: false, code: 'unsafe-inbox', error: `${FILE_INBOX_DIR} is a link; refusing to write through it` };
|
|
176
|
+
mkdirSync(dir, { recursive: true });
|
|
177
|
+
const path = join(dir, `${ref.sha256.slice(0, 12)}-${sanitizeBlobName(ref.name)}`);
|
|
178
|
+
if (existsSync(path) || (() => { try {
|
|
179
|
+
lstatSync(path);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return false;
|
|
184
|
+
} })()) {
|
|
185
|
+
// Already landed (same sha prefix + name): reuse only when it IS these bytes and not a link.
|
|
186
|
+
if (lstatSync(path).isSymbolicLink() || createHash('sha256').update(readFileSync(path)).digest('hex') !== ref.sha256)
|
|
187
|
+
return { ok: false, code: 'exists', error: `${path} exists with other content` };
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
writeFileSync(path, bytes, { flag: 'wx', mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
return { ok: true, path, size: bytes.byteLength, sha256: ref.sha256, name: sanitizeBlobName(ref.name), from: ref.from };
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
return { ok: false, code: 'write-failed', error: error instanceof Error ? error.message : String(error) };
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
return { registerFileSend, registerFileFetch };
|
|
201
|
+
}
|