@yeaft/webchat-agent 0.1.525 → 0.1.526
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/package.json +1 -1
- package/unify/groups/group-store.js +7 -3
- package/unify/groups/ids.js +58 -0
- package/unify/groups/index.js +3 -0
- package/unify/groups/roster.js +5 -1
- package/unify/routing/index.js +19 -0
- package/unify/routing/loop-guard.js +139 -0
- package/unify/routing/router.js +174 -0
- package/unify/tools/index.js +6 -0
- package/unify/tools/route-forward.js +117 -0
package/package.json
CHANGED
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
} from 'fs';
|
|
27
27
|
import { join } from 'path';
|
|
28
28
|
import { writeAtomic, openLog } from '../storage/index.js';
|
|
29
|
-
import { nextMsgId, isReservedVpId, ReservedVpIdError } from './ids.js';
|
|
29
|
+
import { nextMsgId, isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
|
|
30
30
|
|
|
31
31
|
const GROUP_FILE = 'group.json';
|
|
32
32
|
const MESSAGES_DIR = 'messages';
|
|
@@ -113,9 +113,13 @@ export function createGroup(groupsRoot, spec) {
|
|
|
113
113
|
const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
|
|
114
114
|
for (const v of roster) {
|
|
115
115
|
if (isReservedVpId(v)) throw new ReservedVpIdError(v);
|
|
116
|
+
const verdict = validateVpId(v);
|
|
117
|
+
if (!verdict.ok) throw new InvalidVpIdError(v, verdict.reason);
|
|
116
118
|
}
|
|
117
|
-
if (spec.defaultVpId
|
|
118
|
-
throw new ReservedVpIdError(spec.defaultVpId);
|
|
119
|
+
if (spec.defaultVpId) {
|
|
120
|
+
if (isReservedVpId(spec.defaultVpId)) throw new ReservedVpIdError(spec.defaultVpId);
|
|
121
|
+
const dverdict = validateVpId(spec.defaultVpId);
|
|
122
|
+
if (!dverdict.ok) throw new InvalidVpIdError(spec.defaultVpId, dverdict.reason);
|
|
119
123
|
}
|
|
120
124
|
const meta = {
|
|
121
125
|
id: spec.id,
|
package/unify/groups/ids.js
CHANGED
|
@@ -69,3 +69,61 @@ export class ReservedVpIdError extends Error {
|
|
|
69
69
|
this.vpId = vpId;
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Character + shape whitelist for a user-facing vpId. Stricter than
|
|
75
|
+
* `parseMentions` (Postel's law: be lenient on input, be strict on storage).
|
|
76
|
+
*
|
|
77
|
+
* Rules (task-334d, absorbing 334b follow-up #1):
|
|
78
|
+
* - Must be a non-empty string
|
|
79
|
+
* - Length 1..40
|
|
80
|
+
* - Characters: `[A-Za-z0-9_-]` only
|
|
81
|
+
* - Must NOT start with `_` (underscore reserved for future system roles)
|
|
82
|
+
* - Must NOT be purely digits
|
|
83
|
+
* - Must NOT be a reserved vpId (delegates to isReservedVpId)
|
|
84
|
+
*
|
|
85
|
+
* Returns `{ ok, reason? }`. The callsites that want a boolean use
|
|
86
|
+
* `isValidVpId(id)` (truthy only when ok). Error strings are stable so UI
|
|
87
|
+
* can key on them for i18n (prev-1 nit: UX-friendly messages come later;
|
|
88
|
+
* this layer exposes raw reasons).
|
|
89
|
+
*/
|
|
90
|
+
const VP_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
91
|
+
const PURE_DIGITS_RE = /^[0-9]+$/;
|
|
92
|
+
const VP_ID_MAX_LEN = 40;
|
|
93
|
+
|
|
94
|
+
export function validateVpId(id) {
|
|
95
|
+
if (!id || typeof id !== 'string') {
|
|
96
|
+
return { ok: false, reason: 'empty_or_non_string' };
|
|
97
|
+
}
|
|
98
|
+
if (id.length > VP_ID_MAX_LEN) {
|
|
99
|
+
return { ok: false, reason: 'too_long' };
|
|
100
|
+
}
|
|
101
|
+
if (!VP_ID_RE.test(id)) {
|
|
102
|
+
return { ok: false, reason: 'illegal_character' };
|
|
103
|
+
}
|
|
104
|
+
if (id.startsWith('_')) {
|
|
105
|
+
return { ok: false, reason: 'underscore_prefix_reserved' };
|
|
106
|
+
}
|
|
107
|
+
if (PURE_DIGITS_RE.test(id)) {
|
|
108
|
+
return { ok: false, reason: 'pure_digits' };
|
|
109
|
+
}
|
|
110
|
+
if (isReservedVpId(id)) {
|
|
111
|
+
return { ok: false, reason: 'reserved' };
|
|
112
|
+
}
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Convenience boolean wrapper for call-sites that don't care about the reason. */
|
|
117
|
+
export function isValidVpId(id) {
|
|
118
|
+
return validateVpId(id).ok;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Thrown by CRUD entry points on an invalid (non-reserved) vpId shape. */
|
|
122
|
+
export class InvalidVpIdError extends Error {
|
|
123
|
+
constructor(vpId, reason) {
|
|
124
|
+
super(`vpId "${vpId}" is invalid (${reason})`);
|
|
125
|
+
this.name = 'InvalidVpIdError';
|
|
126
|
+
this.vpId = vpId;
|
|
127
|
+
this.reason = reason;
|
|
128
|
+
}
|
|
129
|
+
}
|
package/unify/groups/index.js
CHANGED
package/unify/groups/roster.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* and optionally notify listeners.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { isReservedVpId, ReservedVpIdError } from './ids.js';
|
|
15
|
+
import { isReservedVpId, ReservedVpIdError, validateVpId, InvalidVpIdError } from './ids.js';
|
|
16
16
|
|
|
17
17
|
/** Returns a cloned roster array with `vpId` appended if not already present. */
|
|
18
18
|
export function addVp(meta, vpId) {
|
|
@@ -22,6 +22,10 @@ export function addVp(meta, vpId) {
|
|
|
22
22
|
if (isReservedVpId(vpId)) {
|
|
23
23
|
throw new ReservedVpIdError(vpId);
|
|
24
24
|
}
|
|
25
|
+
const verdict = validateVpId(vpId);
|
|
26
|
+
if (!verdict.ok) {
|
|
27
|
+
throw new InvalidVpIdError(vpId, verdict.reason);
|
|
28
|
+
}
|
|
25
29
|
const roster = meta.roster.slice();
|
|
26
30
|
if (!roster.includes(vpId)) roster.push(vpId);
|
|
27
31
|
const defaultVpId = meta.defaultVpId || roster[0] || null;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* routing/ — VP-side @-forward dispatch (task-334d).
|
|
3
|
+
*
|
|
4
|
+
* Layered on top of 334b Group Coordinator:
|
|
5
|
+
* - loop-guard: chain-depth + rate-window protection
|
|
6
|
+
* - router: route_forward → coordinator.ingest wrapper with guard,
|
|
7
|
+
* self-reject, task.members forwarding, and causedBy chain.
|
|
8
|
+
*
|
|
9
|
+
* See agent/unify/tools/route-forward.js for the VP-facing tool.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
createLoopGuard,
|
|
14
|
+
extendCausedBy,
|
|
15
|
+
MAX_CHAIN_DEPTH,
|
|
16
|
+
DEFAULT_WINDOW_MS,
|
|
17
|
+
DEFAULT_MAX_HITS_PER_WINDOW,
|
|
18
|
+
} from './loop-guard.js';
|
|
19
|
+
export { createRouter } from './router.js';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loop-guard.js — Routing loop protection for task-334d.
|
|
3
|
+
*
|
|
4
|
+
* Prevents two classes of runaway fan-out:
|
|
5
|
+
*
|
|
6
|
+
* 1. Chain depth — a message's `causedBy` chain (A → @B → @C → @A …) must
|
|
7
|
+
* not exceed a max depth. Each route_forward call stamps the outbound
|
|
8
|
+
* envelope's `meta.causedBy` with a chain of msgIds, and the guard
|
|
9
|
+
* rejects when the chain length would exceed MAX_CHAIN_DEPTH (10).
|
|
10
|
+
*
|
|
11
|
+
* 2. Rate throttle — within a sliding window (WINDOW_MS = 5000, default
|
|
12
|
+
* MAX_HITS_PER_WINDOW = 8), a single (groupId, vpId) target may be
|
|
13
|
+
* @-forwarded at most N times. On overflow, the forward returns a
|
|
14
|
+
* `throttled` error and does NOT dispatch. The counter uses a simple
|
|
15
|
+
* ring (timestamps array) so expired hits are collected on insert.
|
|
16
|
+
*
|
|
17
|
+
* Both limits are per-group-per-target; chains are tracked per msgId root.
|
|
18
|
+
* The guard is a pure in-memory helper — no persistence — because the
|
|
19
|
+
* threat model is one runaway turn storm within a single process tick.
|
|
20
|
+
*
|
|
21
|
+
* Integration contract (routing/router.js):
|
|
22
|
+
* - router stamps envelope.meta.causedBy = [...prevChain, currentMsgId]
|
|
23
|
+
* - router calls `guard.check({ groupId, targetVpId, chain })` BEFORE
|
|
24
|
+
* calling coordinator.deliver; on `{ ok: false, reason }` returns a
|
|
25
|
+
* tool-level error.
|
|
26
|
+
* - on ok=true, router calls `guard.record({ groupId, targetVpId })` to
|
|
27
|
+
* advance the rate counter.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
export const MAX_CHAIN_DEPTH = 10;
|
|
31
|
+
export const DEFAULT_WINDOW_MS = 5_000;
|
|
32
|
+
export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a new loop guard. Safe to share across a single web-bridge process.
|
|
36
|
+
*
|
|
37
|
+
* @param {{
|
|
38
|
+
* maxChainDepth?: number,
|
|
39
|
+
* windowMs?: number,
|
|
40
|
+
* maxHitsPerWindow?: number,
|
|
41
|
+
* now?: () => number, // injectable for tests
|
|
42
|
+
* }} [options]
|
|
43
|
+
*/
|
|
44
|
+
export function createLoopGuard(options = {}) {
|
|
45
|
+
const maxChainDepth = options.maxChainDepth ?? MAX_CHAIN_DEPTH;
|
|
46
|
+
const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
|
|
47
|
+
const maxHits = options.maxHitsPerWindow ?? DEFAULT_MAX_HITS_PER_WINDOW;
|
|
48
|
+
const now = typeof options.now === 'function' ? options.now : Date.now;
|
|
49
|
+
|
|
50
|
+
/** Map<"groupId::vpId", number[]> — sorted ascending timestamps. */
|
|
51
|
+
const hits = new Map();
|
|
52
|
+
|
|
53
|
+
function key(groupId, vpId) { return `${groupId}::${vpId}`; }
|
|
54
|
+
|
|
55
|
+
function trim(arr, cutoff) {
|
|
56
|
+
let i = 0;
|
|
57
|
+
while (i < arr.length && arr[i] < cutoff) i += 1;
|
|
58
|
+
if (i > 0) arr.splice(0, i);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a forward to (groupId, vpId) with the supplied causedBy
|
|
64
|
+
* chain is permitted. Does NOT record — call record() after the caller
|
|
65
|
+
* decides to proceed (keeps dry-run / simulation honest).
|
|
66
|
+
*
|
|
67
|
+
* @param {{ groupId:string, targetVpId:string, chain?:string[] }} args
|
|
68
|
+
* @returns {{ ok:true } | { ok:false, reason:'chain_depth_exceeded'|'throttled', detail?:any }}
|
|
69
|
+
*/
|
|
70
|
+
check({ groupId, targetVpId, chain = [] }) {
|
|
71
|
+
if (!groupId || !targetVpId) {
|
|
72
|
+
return { ok: false, reason: 'chain_depth_exceeded', detail: { missing: true } };
|
|
73
|
+
}
|
|
74
|
+
if (Array.isArray(chain) && chain.length >= maxChainDepth) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
reason: 'chain_depth_exceeded',
|
|
78
|
+
detail: { depth: chain.length, limit: maxChainDepth },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const k = key(groupId, targetVpId);
|
|
82
|
+
const arr = hits.get(k);
|
|
83
|
+
if (arr) {
|
|
84
|
+
const cutoff = now() - windowMs;
|
|
85
|
+
trim(arr, cutoff);
|
|
86
|
+
if (arr.length >= maxHits) {
|
|
87
|
+
return {
|
|
88
|
+
ok: false,
|
|
89
|
+
reason: 'throttled',
|
|
90
|
+
detail: { hits: arr.length, limit: maxHits, windowMs },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ok: true };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
/** Record a successful forward — advances the rate counter. */
|
|
98
|
+
record({ groupId, targetVpId }) {
|
|
99
|
+
if (!groupId || !targetVpId) return;
|
|
100
|
+
const k = key(groupId, targetVpId);
|
|
101
|
+
let arr = hits.get(k);
|
|
102
|
+
if (!arr) {
|
|
103
|
+
arr = [];
|
|
104
|
+
hits.set(k, arr);
|
|
105
|
+
}
|
|
106
|
+
const cutoff = now() - windowMs;
|
|
107
|
+
trim(arr, cutoff);
|
|
108
|
+
arr.push(now());
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
/** Snapshot for tests / debug. */
|
|
112
|
+
snapshot() {
|
|
113
|
+
const out = {};
|
|
114
|
+
for (const [k, arr] of hits) out[k] = arr.slice();
|
|
115
|
+
return { hits: out, maxChainDepth, windowMs, maxHits };
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
/** Wipe all counters (tests). */
|
|
119
|
+
reset() { hits.clear(); },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a causedBy chain array from the inbound envelope + the current msgId.
|
|
125
|
+
* Returns a fresh array (no mutation of envelope).
|
|
126
|
+
*
|
|
127
|
+
* @param {any} inboundEnvelope — envelope that the VP is currently handling
|
|
128
|
+
* @param {string} currentMsgId — the NEW outbound msg about to be emitted
|
|
129
|
+
*/
|
|
130
|
+
export function extendCausedBy(inboundEnvelope, currentMsgId) {
|
|
131
|
+
const prev = inboundEnvelope?.msg?.meta?.causedBy;
|
|
132
|
+
const chain = Array.isArray(prev) ? prev.slice() : [];
|
|
133
|
+
// Also include the inbound msgId as the direct cause, if present and not
|
|
134
|
+
// already in the chain.
|
|
135
|
+
const inboundId = inboundEnvelope?.msg?.id;
|
|
136
|
+
if (inboundId && !chain.includes(inboundId)) chain.push(inboundId);
|
|
137
|
+
if (currentMsgId && !chain.includes(currentMsgId)) chain.push(currentMsgId);
|
|
138
|
+
return chain;
|
|
139
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* router.js — VP-side @-forward dispatch (task-334d).
|
|
3
|
+
*
|
|
4
|
+
* Wraps GroupCoordinator with the extra rules that apply when the sender is
|
|
5
|
+
* a VP (not a user). Architecture §6:
|
|
6
|
+
*
|
|
7
|
+
* - VPs do NOT trigger text-@-routing. Free-text @foo from a VP is purely
|
|
8
|
+
* surface noise. To hand off a turn, a VP must call the `route_forward`
|
|
9
|
+
* tool — which lands here.
|
|
10
|
+
* - route_forward(to, text, reason?) MUST go through Coordinator.dispatch
|
|
11
|
+
* so @all fan-out caps, task.members filtering, and persistence stay
|
|
12
|
+
* consistent with user-initiated routing.
|
|
13
|
+
* - Self-forward (to === senderVpId) is a hard tool-level error; VPs
|
|
14
|
+
* should "speak" via normal turn output, not route_forward.
|
|
15
|
+
* - Loop guard: chain depth + rate throttle (see loop-guard.js).
|
|
16
|
+
*
|
|
17
|
+
* Router stamps `meta.causedBy` with the full chain so downstream Coordinator
|
|
18
|
+
* events carry provenance, and so the guard can refuse runaway chains even
|
|
19
|
+
* after the sending VP finishes its own turn.
|
|
20
|
+
*
|
|
21
|
+
* Hard constraints (inherited from PM directive):
|
|
22
|
+
* (a) Does NOT touch RoleInstance state machine internals (that's 334c).
|
|
23
|
+
* (b) Does NOT touch live-diff (334h).
|
|
24
|
+
* (c) Persistence routes through group.appendMessage via Coordinator.
|
|
25
|
+
* (d) Tool schema uses defineTool (agent/unify/tools/types.js).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { isMember } from '../groups/roster.js';
|
|
29
|
+
import { createLoopGuard, extendCausedBy } from './loop-guard.js';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build a router bound to a single GroupCoordinator + loop guard.
|
|
33
|
+
*
|
|
34
|
+
* @param {{
|
|
35
|
+
* coordinator: import('../groups/coordinator.js').GroupCoordinator,
|
|
36
|
+
* guard?: ReturnType<typeof createLoopGuard>,
|
|
37
|
+
* now?: () => number,
|
|
38
|
+
* }} deps
|
|
39
|
+
*/
|
|
40
|
+
export function createRouter(deps = {}) {
|
|
41
|
+
const { coordinator } = deps;
|
|
42
|
+
if (!coordinator || typeof coordinator.ingest !== 'function') {
|
|
43
|
+
throw new Error('createRouter: coordinator (with ingest()) is required');
|
|
44
|
+
}
|
|
45
|
+
const guard = deps.guard || createLoopGuard({ now: deps.now });
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Forward a message from a VP to another VP (or @all). Routes through
|
|
49
|
+
* Coordinator so all MVP rules hold (fanout cap, task.members, persist).
|
|
50
|
+
*
|
|
51
|
+
* @param {{
|
|
52
|
+
* from: string, // sender vpId (required; never 'user')
|
|
53
|
+
* to: string, // target vpId OR 'all'
|
|
54
|
+
* text: string,
|
|
55
|
+
* reason?: string, // optional human-readable rationale, stamped on meta
|
|
56
|
+
* taskId?: string|null,
|
|
57
|
+
* inboundEnvelope?: any, // the envelope the sender is currently handling
|
|
58
|
+
* // (drives causedBy chain & loop guard)
|
|
59
|
+
* }} args
|
|
60
|
+
* @param {{ taskMembers?: string[] }} [opts] — forwarded to coordinator.ingest
|
|
61
|
+
* @returns {{
|
|
62
|
+
* ok: boolean,
|
|
63
|
+
* error?: string,
|
|
64
|
+
* dispatched?: string[],
|
|
65
|
+
* report?: import('../groups/coordinator.js').DispatchReport,
|
|
66
|
+
* }}
|
|
67
|
+
*/
|
|
68
|
+
function forward(args, opts = {}) {
|
|
69
|
+
if (!args || typeof args !== 'object') {
|
|
70
|
+
return { ok: false, error: 'args_required' };
|
|
71
|
+
}
|
|
72
|
+
const from = args.from;
|
|
73
|
+
const to = args.to;
|
|
74
|
+
const text = args.text;
|
|
75
|
+
|
|
76
|
+
if (!from || typeof from !== 'string') {
|
|
77
|
+
return { ok: false, error: 'from_required' };
|
|
78
|
+
}
|
|
79
|
+
if (from === 'user') {
|
|
80
|
+
// Users don't use route_forward — they type @ in chat. Policy guard.
|
|
81
|
+
return { ok: false, error: 'route_forward_is_vp_only' };
|
|
82
|
+
}
|
|
83
|
+
if (!to || typeof to !== 'string') {
|
|
84
|
+
return { ok: false, error: 'to_required' };
|
|
85
|
+
}
|
|
86
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
87
|
+
return { ok: false, error: 'text_required' };
|
|
88
|
+
}
|
|
89
|
+
if (to === from) {
|
|
90
|
+
return { ok: false, error: 'self_forward_rejected' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const meta = coordinator.group.getMeta();
|
|
94
|
+
if (!meta) return { ok: false, error: 'group_not_initialised' };
|
|
95
|
+
|
|
96
|
+
// Roster membership — `all` is reserved broadcast sentinel handled by
|
|
97
|
+
// coordinator; anything else must be a real member so we fail fast with
|
|
98
|
+
// a VP-friendly error before hitting Coordinator.
|
|
99
|
+
if (to !== 'all' && !isMember(meta, to)) {
|
|
100
|
+
return { ok: false, error: 'target_not_in_roster' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Build the causedBy chain BEFORE constructing the synthetic user-like
|
|
104
|
+
// message. We don't know the new msgId yet (coordinator mints it on
|
|
105
|
+
// appendMessage), so we only include the inbound chain + inbound msgId.
|
|
106
|
+
// The guard runs against the *pre-dispatch* chain; that matches the
|
|
107
|
+
// spec's intent ("depth of forwards already taken").
|
|
108
|
+
const chain = extendCausedBy(args.inboundEnvelope || null, null);
|
|
109
|
+
|
|
110
|
+
// Loop guard: for broadcast, use 'all' as the target key so one VP
|
|
111
|
+
// spamming @all still gets throttled even if each cycle hits different
|
|
112
|
+
// member inboxes.
|
|
113
|
+
const guardKey = to === 'all' ? 'all' : to;
|
|
114
|
+
const verdict = guard.check({
|
|
115
|
+
groupId: meta.id,
|
|
116
|
+
targetVpId: guardKey,
|
|
117
|
+
chain,
|
|
118
|
+
});
|
|
119
|
+
if (!verdict.ok) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
error: verdict.reason, // 'chain_depth_exceeded' | 'throttled'
|
|
123
|
+
detail: verdict.detail || null,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Synthesize an injection message — coordinator's `ingest` expects the
|
|
128
|
+
// {from, role, text} shape. We set role='user' ONLY because that's the
|
|
129
|
+
// code path that triggers @-routing; semantically it is a VP-initiated
|
|
130
|
+
// injection. The `from` field preserves the real sender vpId, and we
|
|
131
|
+
// stamp meta.synthetic + meta.injectedBy so downstream auditors can
|
|
132
|
+
// tell it apart from real user input.
|
|
133
|
+
//
|
|
134
|
+
// Why not add a third role to Coordinator? Scope discipline: Coordinator
|
|
135
|
+
// (334b) owns user vs VP branching. A third branch would force edits
|
|
136
|
+
// across both 334b and 334d for one hop. Setting role='user' with
|
|
137
|
+
// synthetic meta keeps the Coordinator API frozen — and `from` still
|
|
138
|
+
// reflects the real author, which is what the guard keys on anyway.
|
|
139
|
+
const injectText = to === 'all'
|
|
140
|
+
? `@all ${text}`
|
|
141
|
+
: `@${to} ${text}`;
|
|
142
|
+
|
|
143
|
+
const report = coordinator.ingest(
|
|
144
|
+
{
|
|
145
|
+
from, // real VP id — preserved for provenance
|
|
146
|
+
role: 'user', // triggers coordinator dispatch path
|
|
147
|
+
text: injectText,
|
|
148
|
+
taskId: args.taskId ?? null,
|
|
149
|
+
meta: {
|
|
150
|
+
synthetic: true,
|
|
151
|
+
injectedBy: 'route_forward',
|
|
152
|
+
senderVpId: from,
|
|
153
|
+
reason: args.reason || null,
|
|
154
|
+
causedBy: chain,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
opts,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
// Record AFTER Coordinator accepts. If Coordinator produced zero
|
|
161
|
+
// dispatches (e.g. task.members gate) we still count it as a hit —
|
|
162
|
+
// the forwarder still tried, and the guard's job is to throttle the
|
|
163
|
+
// sender's ability to keep trying.
|
|
164
|
+
guard.record({ groupId: meta.id, targetVpId: guardKey });
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
dispatched: report.dispatched.slice(),
|
|
169
|
+
report,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { forward, guard, coordinator };
|
|
174
|
+
}
|
package/unify/tools/index.js
CHANGED
|
@@ -43,6 +43,9 @@ import waitAgent from './wait-agent.js';
|
|
|
43
43
|
import closeAgent from './close-agent.js';
|
|
44
44
|
import listAgents from './list-agents.js';
|
|
45
45
|
|
|
46
|
+
// --- P1 Routing tools (task-334d) ---
|
|
47
|
+
import routeForward from './route-forward.js';
|
|
48
|
+
|
|
46
49
|
// --- P1 Task tools ---
|
|
47
50
|
import {
|
|
48
51
|
taskCreate,
|
|
@@ -116,6 +119,9 @@ export const allTools = [
|
|
|
116
119
|
closeAgent,
|
|
117
120
|
listAgents,
|
|
118
121
|
|
|
122
|
+
// P1 Routing (task-334d)
|
|
123
|
+
routeForward,
|
|
124
|
+
|
|
119
125
|
// P1 Task
|
|
120
126
|
taskCreate,
|
|
121
127
|
taskUpdate,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* route-forward.js — VP-facing `route_forward` tool (task-334d).
|
|
3
|
+
*
|
|
4
|
+
* VPs cannot trigger @-routing by writing @foo into chat text (that's the
|
|
5
|
+
* user-only coordinator branch from §6). To hand a turn to another VP, the
|
|
6
|
+
* VP must call this tool.
|
|
7
|
+
*
|
|
8
|
+
* Tool contract:
|
|
9
|
+
* route_forward({ to, text, reason? }) → status JSON
|
|
10
|
+
* - to: target vpId, OR the literal 'all' for broadcast
|
|
11
|
+
* - text: the message body to relay
|
|
12
|
+
* - reason: optional string — why we're forwarding; logged on meta
|
|
13
|
+
*
|
|
14
|
+
* Ctx expectations (supplied by RoleInstance Engine wiring):
|
|
15
|
+
* ctx.router — createRouter() instance for the active group
|
|
16
|
+
* ctx.senderVpId — the VP that owns the running turn
|
|
17
|
+
* ctx.inboundEnvelope — the envelope currently being processed (loop guard)
|
|
18
|
+
* ctx.taskId — current task scope, if any
|
|
19
|
+
* ctx.taskMembers — optional member allowlist for task-scoped groups
|
|
20
|
+
*
|
|
21
|
+
* Return is always a JSON string so the LLM can reason about ok/error. On
|
|
22
|
+
* failure the tool does NOT throw (that would kill the turn); it returns
|
|
23
|
+
* `{ ok: false, error }` so the VP can pivot (apologise, retry, ...).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { defineTool } from './types.js';
|
|
27
|
+
|
|
28
|
+
export default defineTool({
|
|
29
|
+
name: 'RouteForward',
|
|
30
|
+
description: `Hand this turn off to another VP in the same group.
|
|
31
|
+
|
|
32
|
+
Use this tool — NOT free-text @mentions — to route a question or task to
|
|
33
|
+
another VP. VP-authored @mentions in chat text are NOT automatically routed
|
|
34
|
+
(the group coordinator only text-routes for user messages); you must call
|
|
35
|
+
RouteForward for the hand-off to take effect.
|
|
36
|
+
|
|
37
|
+
Arguments:
|
|
38
|
+
- to (string): target vpId, or the literal "all" to broadcast to every
|
|
39
|
+
other member of the group (subject to the per-group fan-out cap).
|
|
40
|
+
- text (string): the message body to send on your behalf.
|
|
41
|
+
- reason (string, optional): short rationale for the forward, recorded on
|
|
42
|
+
the message meta for audit / UI display.
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- Forwarding to yourself is rejected (self_forward_rejected).
|
|
46
|
+
- Forwarding to a non-member is rejected (target_not_in_roster).
|
|
47
|
+
- Forwards carry a causedBy chain; chains deeper than 10 hops are blocked
|
|
48
|
+
(chain_depth_exceeded).
|
|
49
|
+
- A single target may be forwarded to at most 8 times per 5-second window
|
|
50
|
+
per group (throttled).
|
|
51
|
+
|
|
52
|
+
Returns JSON: { ok, dispatched?, error?, detail? }.`,
|
|
53
|
+
parameters: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
properties: {
|
|
56
|
+
to: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Target vpId, or "all" for broadcast',
|
|
59
|
+
},
|
|
60
|
+
text: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'The message body to forward',
|
|
63
|
+
},
|
|
64
|
+
reason: {
|
|
65
|
+
type: 'string',
|
|
66
|
+
description: 'Optional: short rationale for the forward',
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
required: ['to', 'text'],
|
|
70
|
+
},
|
|
71
|
+
isConcurrencySafe: () => false,
|
|
72
|
+
isReadOnly: () => false,
|
|
73
|
+
async execute(input, ctx = {}) {
|
|
74
|
+
const { to, text, reason } = input || {};
|
|
75
|
+
if (!to || typeof to !== 'string') {
|
|
76
|
+
return JSON.stringify({ ok: false, error: 'to_required' });
|
|
77
|
+
}
|
|
78
|
+
if (typeof text !== 'string' || text.length === 0) {
|
|
79
|
+
return JSON.stringify({ ok: false, error: 'text_required' });
|
|
80
|
+
}
|
|
81
|
+
const router = ctx.router;
|
|
82
|
+
const senderVpId = ctx.senderVpId;
|
|
83
|
+
if (!router || typeof router.forward !== 'function') {
|
|
84
|
+
return JSON.stringify({ ok: false, error: 'router_unavailable' });
|
|
85
|
+
}
|
|
86
|
+
if (!senderVpId) {
|
|
87
|
+
return JSON.stringify({ ok: false, error: 'sender_unknown' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const result = router.forward(
|
|
91
|
+
{
|
|
92
|
+
from: senderVpId,
|
|
93
|
+
to,
|
|
94
|
+
text,
|
|
95
|
+
reason: reason || null,
|
|
96
|
+
taskId: ctx.taskId ?? null,
|
|
97
|
+
inboundEnvelope: ctx.inboundEnvelope ?? null,
|
|
98
|
+
},
|
|
99
|
+
{ taskMembers: ctx.taskMembers },
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
if (!result.ok) {
|
|
103
|
+
return JSON.stringify({
|
|
104
|
+
ok: false,
|
|
105
|
+
error: result.error,
|
|
106
|
+
detail: result.detail || null,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return JSON.stringify({
|
|
110
|
+
ok: true,
|
|
111
|
+
dispatched: result.dispatched,
|
|
112
|
+
broadcast: Boolean(result.report?.broadcast),
|
|
113
|
+
truncatedAtFanOutCap: Boolean(result.report?.truncatedAtFanOutCap),
|
|
114
|
+
errors: result.report?.errors || [],
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|