@yeaft/webchat-agent 0.1.525 → 0.1.527

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.525",
3
+ "version": "0.1.527",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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 && isReservedVpId(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,
@@ -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
+ }
@@ -44,4 +44,7 @@ export {
44
44
  isReservedVpId,
45
45
  RESERVED_VP_IDS,
46
46
  ReservedVpIdError,
47
+ isValidVpId,
48
+ validateVpId,
49
+ InvalidVpIdError,
47
50
  } from './ids.js';
@@ -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,21 @@
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
+ DEFAULT_MAX_KEYS,
19
+ DEFAULT_TTL_MULTIPLIER,
20
+ } from './loop-guard.js';
21
+ export { createRouter } from './router.js';
@@ -0,0 +1,228 @@
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
+ * Long-running process hygiene (N1, task-334d-followup):
22
+ * The `hits` Map is keyed by "groupId::vpId" and would otherwise grow
23
+ * unboundedly over a long session. Two complementary bounds:
24
+ * - TTL sweep: on each NEW key insert, drop entries whose most
25
+ * recent hit is older than `ttlMultiplier × windowMs` (default 2×).
26
+ * Those entries can never throttle anyone regardless — their rate
27
+ * window is already fully expired. Amortised O(n) but only on new
28
+ * keys, so normal hot-path cost stays O(1).
29
+ * - LRU cap: after the TTL sweep, if size > maxKeys (default 1000)
30
+ * the Map's insertion-order head (oldest-used key) is evicted until
31
+ * back under the cap. `check()` and `record()` both `touch()` a key
32
+ * (delete+re-set) so recency is refreshed on every access.
33
+ * Behavior invariants preserved: the `'all'` broadcast sentinel, the
34
+ * `now()` injection seam, and the chain-depth check are untouched — only
35
+ * the eviction path is new.
36
+ *
37
+ * Integration contract (routing/router.js):
38
+ * - router stamps envelope.meta.causedBy = [...prevChain, currentMsgId]
39
+ * - router calls `guard.check({ groupId, targetVpId, chain })` BEFORE
40
+ * calling coordinator.deliver; on `{ ok: false, reason }` returns a
41
+ * tool-level error.
42
+ * - on ok=true, router calls `guard.record({ groupId, targetVpId })` to
43
+ * advance the rate counter.
44
+ */
45
+
46
+ export const MAX_CHAIN_DEPTH = 10;
47
+ export const DEFAULT_WINDOW_MS = 5_000;
48
+ export const DEFAULT_MAX_HITS_PER_WINDOW = 8;
49
+ export const DEFAULT_MAX_KEYS = 1_000;
50
+ export const DEFAULT_TTL_MULTIPLIER = 2;
51
+
52
+ /**
53
+ * Build a new loop guard. Safe to share across a single web-bridge process.
54
+ *
55
+ * @param {{
56
+ * maxChainDepth?: number,
57
+ * windowMs?: number,
58
+ * maxHitsPerWindow?: number,
59
+ * maxKeys?: number, // N1: LRU cap (default 1000)
60
+ * ttlMultiplier?: number, // N1: evict keys idle > ttlMultiplier*windowMs
61
+ * now?: () => number, // injectable for tests
62
+ * }} [options]
63
+ */
64
+ export function createLoopGuard(options = {}) {
65
+ const maxChainDepth = options.maxChainDepth ?? MAX_CHAIN_DEPTH;
66
+ const windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
67
+ const maxHits = options.maxHitsPerWindow ?? DEFAULT_MAX_HITS_PER_WINDOW;
68
+ const maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
69
+ const ttlMultiplier = options.ttlMultiplier ?? DEFAULT_TTL_MULTIPLIER;
70
+ const now = typeof options.now === 'function' ? options.now : Date.now;
71
+
72
+ /** Map<"groupId::vpId", number[]> — sorted ascending timestamps.
73
+ * Map insertion order doubles as LRU recency: touching (delete+set) on
74
+ * every access keeps the oldest-used entry at the front for eviction. */
75
+ const hits = new Map();
76
+ let evictions = 0;
77
+
78
+ function key(groupId, vpId) { return `${groupId}::${vpId}`; }
79
+
80
+ function trim(arr, cutoff) {
81
+ let i = 0;
82
+ while (i < arr.length && arr[i] < cutoff) i += 1;
83
+ if (i > 0) arr.splice(0, i);
84
+ }
85
+
86
+ /**
87
+ * Touch a key → move to the Map's insertion tail (most-recently-used).
88
+ * Used on BOTH check() and record() paths so a blocked-but-checked
89
+ * target is kept warm as long as something keeps referencing it.
90
+ */
91
+ function touch(k, arr) {
92
+ hits.delete(k);
93
+ hits.set(k, arr);
94
+ }
95
+
96
+ /**
97
+ * Opportunistic TTL sweep: drop keys whose last hit is older than
98
+ * ttlMultiplier × windowMs (i.e. their rate window is fully expired and
99
+ * stale). Called on insert to amortise cleanup across normal traffic,
100
+ * so we never scan on the hot read path.
101
+ */
102
+ function sweepExpired() {
103
+ const ttlCutoff = now() - ttlMultiplier * windowMs;
104
+ for (const [k, arr] of hits) {
105
+ // arr is sorted ascending; the tail is the most-recent hit.
106
+ const last = arr.length > 0 ? arr[arr.length - 1] : -Infinity;
107
+ if (last < ttlCutoff) {
108
+ hits.delete(k);
109
+ evictions += 1;
110
+ }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Enforce the hard LRU cap. Called on insert AFTER sweepExpired so
116
+ * only genuinely hot but stale-enough entries get evicted.
117
+ */
118
+ function enforceCap() {
119
+ while (hits.size > maxKeys) {
120
+ const oldest = hits.keys().next().value;
121
+ if (oldest === undefined) break;
122
+ hits.delete(oldest);
123
+ evictions += 1;
124
+ }
125
+ }
126
+
127
+ return {
128
+ /**
129
+ * Check whether a forward to (groupId, vpId) with the supplied causedBy
130
+ * chain is permitted. Does NOT record — call record() after the caller
131
+ * decides to proceed (keeps dry-run / simulation honest).
132
+ *
133
+ * @param {{ groupId:string, targetVpId:string, chain?:string[] }} args
134
+ * @returns {{ ok:true } | { ok:false, reason:'chain_depth_exceeded'|'throttled', detail?:any }}
135
+ */
136
+ check({ groupId, targetVpId, chain = [] }) {
137
+ if (!groupId || !targetVpId) {
138
+ return { ok: false, reason: 'chain_depth_exceeded', detail: { missing: true } };
139
+ }
140
+ if (Array.isArray(chain) && chain.length >= maxChainDepth) {
141
+ return {
142
+ ok: false,
143
+ reason: 'chain_depth_exceeded',
144
+ detail: { depth: chain.length, limit: maxChainDepth },
145
+ };
146
+ }
147
+ const k = key(groupId, targetVpId);
148
+ const arr = hits.get(k);
149
+ if (arr) {
150
+ const cutoff = now() - windowMs;
151
+ trim(arr, cutoff);
152
+ // Refresh LRU recency — a repeatedly-probed hot target should not
153
+ // be evicted just because it never crosses into record().
154
+ touch(k, arr);
155
+ if (arr.length >= maxHits) {
156
+ return {
157
+ ok: false,
158
+ reason: 'throttled',
159
+ detail: { hits: arr.length, limit: maxHits, windowMs },
160
+ };
161
+ }
162
+ }
163
+ return { ok: true };
164
+ },
165
+
166
+ /** Record a successful forward — advances the rate counter. */
167
+ record({ groupId, targetVpId }) {
168
+ if (!groupId || !targetVpId) return;
169
+ const k = key(groupId, targetVpId);
170
+ let arr = hits.get(k);
171
+ const creating = !arr;
172
+ if (!arr) {
173
+ arr = [];
174
+ hits.set(k, arr);
175
+ }
176
+ const cutoff = now() - windowMs;
177
+ trim(arr, cutoff);
178
+ arr.push(now());
179
+ // Refresh LRU recency for both existing and new keys.
180
+ touch(k, arr);
181
+ // On *new* key creation, opportunistically clean up: first drop
182
+ // fully-expired entries (cheap, bounds unbounded growth), then
183
+ // enforce the hard cap. Skip on the update path to keep hot-loop
184
+ // cost O(1).
185
+ if (creating) {
186
+ sweepExpired();
187
+ enforceCap();
188
+ }
189
+ },
190
+
191
+ /** Snapshot for tests / debug. */
192
+ snapshot() {
193
+ const out = {};
194
+ for (const [k, arr] of hits) out[k] = arr.slice();
195
+ return {
196
+ hits: out,
197
+ maxChainDepth,
198
+ windowMs,
199
+ maxHits,
200
+ maxKeys,
201
+ ttlMultiplier,
202
+ size: hits.size,
203
+ evictions,
204
+ };
205
+ },
206
+
207
+ /** Wipe all counters (tests). */
208
+ reset() { hits.clear(); evictions = 0; },
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Build a causedBy chain array from the inbound envelope + the current msgId.
214
+ * Returns a fresh array (no mutation of envelope).
215
+ *
216
+ * @param {any} inboundEnvelope — envelope that the VP is currently handling
217
+ * @param {string} currentMsgId — the NEW outbound msg about to be emitted
218
+ */
219
+ export function extendCausedBy(inboundEnvelope, currentMsgId) {
220
+ const prev = inboundEnvelope?.msg?.meta?.causedBy;
221
+ const chain = Array.isArray(prev) ? prev.slice() : [];
222
+ // Also include the inbound msgId as the direct cause, if present and not
223
+ // already in the chain.
224
+ const inboundId = inboundEnvelope?.msg?.id;
225
+ if (inboundId && !chain.includes(inboundId)) chain.push(inboundId);
226
+ if (currentMsgId && !chain.includes(currentMsgId)) chain.push(currentMsgId);
227
+ return chain;
228
+ }
@@ -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
+ }
@@ -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
+ });