@animalabs/agent-framework 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +2 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +3 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +45 -0
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +606 -102
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +7 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +15 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +84 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +160 -4
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +5 -1
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/tool-wrapper-prose-guard.d.ts +8 -0
- package/dist/src/tool-wrapper-prose-guard.d.ts.map +1 -0
- package/dist/src/tool-wrapper-prose-guard.js +37 -0
- package/dist/src/tool-wrapper-prose-guard.js.map +1 -0
- package/dist/src/tune-out/coordinator.d.ts +144 -0
- package/dist/src/tune-out/coordinator.d.ts.map +1 -0
- package/dist/src/tune-out/coordinator.js +444 -0
- package/dist/src/tune-out/coordinator.js.map +1 -0
- package/dist/src/tune-out/tools.d.ts +43 -0
- package/dist/src/tune-out/tools.d.ts.map +1 -0
- package/dist/src/tune-out/tools.js +93 -0
- package/dist/src/tune-out/tools.js.map +1 -0
- package/dist/src/types/agent.d.ts +7 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +6 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/test/channel-incoming-targeting.test.d.ts +2 -0
- package/dist/test/channel-incoming-targeting.test.d.ts.map +1 -0
- package/dist/test/channel-incoming-targeting.test.js +81 -0
- package/dist/test/channel-incoming-targeting.test.js.map +1 -0
- package/dist/test/per-agent-delivery.test.d.ts +2 -0
- package/dist/test/per-agent-delivery.test.d.ts.map +1 -0
- package/dist/test/per-agent-delivery.test.js +108 -0
- package/dist/test/per-agent-delivery.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js +485 -0
- package/dist/test/tool-wrapper-prose-guard-integration.test.js.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts +2 -0
- package/dist/test/tool-wrapper-prose-guard.test.d.ts.map +1 -0
- package/dist/test/tool-wrapper-prose-guard.test.js +34 -0
- package/dist/test/tool-wrapper-prose-guard.test.js.map +1 -0
- package/dist/test/tune-out-e2e.test.d.ts +2 -0
- package/dist/test/tune-out-e2e.test.d.ts.map +1 -0
- package/dist/test/tune-out-e2e.test.js +142 -0
- package/dist/test/tune-out-e2e.test.js.map +1 -0
- package/dist/test/tune-out-gate-composition.test.d.ts +2 -0
- package/dist/test/tune-out-gate-composition.test.d.ts.map +1 -0
- package/dist/test/tune-out-gate-composition.test.js +221 -0
- package/dist/test/tune-out-gate-composition.test.js.map +1 -0
- package/dist/test/tune-out-lifecycle.test.d.ts +2 -0
- package/dist/test/tune-out-lifecycle.test.d.ts.map +1 -0
- package/dist/test/tune-out-lifecycle.test.js +121 -0
- package/dist/test/tune-out-lifecycle.test.js.map +1 -0
- package/dist/test/workspace-read-image.test.js +8 -0
- package/dist/test/workspace-read-image.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +3 -0
- package/src/framework.ts +662 -100
- package/src/gate/event-gate.ts +28 -3
- package/src/mcpl/channel-registry.ts +235 -7
- package/src/modules/workspace/index.ts +5 -1
- package/src/tool-wrapper-prose-guard.ts +38 -0
- package/src/tune-out/coordinator.ts +548 -0
- package/src/tune-out/tools.ts +129 -0
- package/src/types/agent.ts +8 -0
- package/src/types/framework.ts +7 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -515,7 +515,13 @@ export class EventGate {
|
|
|
515
515
|
|
|
516
516
|
// Dependency-injected callbacks
|
|
517
517
|
private emitTrace: (event: TraceEventLike) => void;
|
|
518
|
-
private addMessageFn: (
|
|
518
|
+
private addMessageFn: (
|
|
519
|
+
participant: string,
|
|
520
|
+
content: Array<{ type: 'text'; text: string }>,
|
|
521
|
+
metadata?: Record<string, unknown>,
|
|
522
|
+
/** Registry agent to deliver into; absent = primary (historical). */
|
|
523
|
+
forAgent?: string,
|
|
524
|
+
) => unknown;
|
|
519
525
|
private requestInferenceFn: (agentName: string, reason: string, source: string) => void;
|
|
520
526
|
private getAgentNamesFn: () => string[];
|
|
521
527
|
/** Clock injection — keeps the new rate_limit / passive_sample paths
|
|
@@ -527,7 +533,12 @@ export class EventGate {
|
|
|
527
533
|
initialConfig?: GateConfig;
|
|
528
534
|
privilegedUsersPath?: string;
|
|
529
535
|
emitTrace: (event: TraceEventLike) => void;
|
|
530
|
-
addMessage: (
|
|
536
|
+
addMessage: (
|
|
537
|
+
participant: string,
|
|
538
|
+
content: Array<{ type: 'text'; text: string }>,
|
|
539
|
+
metadata?: Record<string, unknown>,
|
|
540
|
+
forAgent?: string,
|
|
541
|
+
) => unknown;
|
|
531
542
|
requestInference: (agentName: string, reason: string, source: string) => void;
|
|
532
543
|
getAgentNames: () => string[];
|
|
533
544
|
/** Optional clock — defaults to Date.now. Tests inject for deterministic time. */
|
|
@@ -1582,10 +1593,13 @@ export class EventGate {
|
|
|
1582
1593
|
// before the inference request so it rides the wake turn (addMessage's
|
|
1583
1594
|
// turn-alive guard defers it to that turn's start if needed).
|
|
1584
1595
|
const nowIso = new Date(this.now()).toISOString().slice(0, 19) + 'Z';
|
|
1596
|
+
// Deliver the notice into the WAKING agent's window — a self-wake
|
|
1597
|
+
// armed by a non-primary agent (e.g. a subconscious cadence) must not
|
|
1598
|
+
// narrate itself into the primary's context.
|
|
1585
1599
|
this.addMessageFn('user', [{
|
|
1586
1600
|
type: 'text',
|
|
1587
1601
|
text: `[self-wake] your ${source} timer (${Math.round(ms / 1000)}s) elapsed — now ${nowIso}`,
|
|
1588
|
-
}], { source: 'gate:self-wake' });
|
|
1602
|
+
}], { source: 'gate:self-wake' }, agentName);
|
|
1589
1603
|
this.requestInferenceFn(
|
|
1590
1604
|
agentName,
|
|
1591
1605
|
`self-scheduled wake (${source}, ${Math.round(ms / 1000)}s)`,
|
|
@@ -1659,6 +1673,17 @@ export class EventGate {
|
|
|
1659
1673
|
}
|
|
1660
1674
|
|
|
1661
1675
|
/** Load the privileged-users file (bare array or { userIds: [...] }). */
|
|
1676
|
+
/**
|
|
1677
|
+
* Whether an author id is on the privileged-users list (hot-reloaded).
|
|
1678
|
+
* Shared by sleep bypass and tune-out wake classification (#77: wakes are
|
|
1679
|
+
* "mentions, gate-privileged authors, etc.").
|
|
1680
|
+
*/
|
|
1681
|
+
isPrivilegedUser(authorId: string | null | undefined): boolean {
|
|
1682
|
+
if (!authorId) return false;
|
|
1683
|
+
this.loadPrivileged();
|
|
1684
|
+
return this.privilegedUserIds.has(authorId);
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1662
1687
|
private loadPrivileged(): void {
|
|
1663
1688
|
if (!this.privilegedUsersPath) return;
|
|
1664
1689
|
try {
|
|
@@ -45,14 +45,53 @@ import { CapabilityGrant } from './capability-grant.js';
|
|
|
45
45
|
const TYPING_INTERVAL_MS = 7_000;
|
|
46
46
|
const CHANNEL_LIFECYCLE_LOG_ID = 'mcpl/channel-lifecycle';
|
|
47
47
|
|
|
48
|
-
type DesiredChannelState = 'open' | 'closed';
|
|
48
|
+
type DesiredChannelState = 'open' | 'closed' | 'tuned-out';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Parameters of an active tune-out (issue #77), carried on the
|
|
52
|
+
* 'desired-state' record entering the tuned-out state and projected into
|
|
53
|
+
* `desiredStates`. A tuned-out channel stays OPEN at the transport (traffic
|
|
54
|
+
* must keep arriving for the subconscious); the divert-don't-wake behavior
|
|
55
|
+
* is applied downstream at ingestion.
|
|
56
|
+
*/
|
|
57
|
+
export interface TuneOutParams {
|
|
58
|
+
/** Identity of this tune-out epoch. Stamped into diverted messages
|
|
59
|
+
* (`metadata.tuneOut = { epochId }`) for permanent main-view exclusion
|
|
60
|
+
* and per-epoch audit; also keys durable wake counting. */
|
|
61
|
+
epochId: string;
|
|
62
|
+
/** Subconscious summary cadence, in seconds. */
|
|
63
|
+
cadenceSeconds: number;
|
|
64
|
+
/** Maximum messages dumped raw at cancel; above the cap the subconscious
|
|
65
|
+
* curates a digest and `fetch_history` covers the rest. */
|
|
66
|
+
backlogCap: number;
|
|
67
|
+
/** Wake invocations before the tune-out auto-cancels. */
|
|
68
|
+
maxWakes: number;
|
|
69
|
+
/** Chronicle sequence when the tune-out began — window anchor + audit bound. */
|
|
70
|
+
startedAtSequence: number;
|
|
71
|
+
/** Absolute wall-clock deadline (epoch ms). When set, the tune-out
|
|
72
|
+
* auto-cancels at this time via the standard cancel flow ("duration
|
|
73
|
+
* elapsed") — the agent's self-binding attention budget (#77 "for a
|
|
74
|
+
* period chosen by the agent"). Unset = until cancelled. */
|
|
75
|
+
expiresAtMs?: number;
|
|
76
|
+
}
|
|
49
77
|
|
|
50
78
|
interface ChannelLifecycleEvent {
|
|
51
|
-
kind:
|
|
79
|
+
kind:
|
|
80
|
+
| 'desired-state'
|
|
81
|
+
| 'legacy-policy-migrated'
|
|
82
|
+
| 'invitation-declined'
|
|
83
|
+
| 'tune-out-wake';
|
|
52
84
|
serverId: string;
|
|
53
85
|
timestamp: string;
|
|
54
86
|
channelId?: string;
|
|
55
87
|
desired?: DesiredChannelState;
|
|
88
|
+
/** Present when desired === 'tuned-out'. */
|
|
89
|
+
tuneOut?: TuneOutParams;
|
|
90
|
+
/** kind 'tune-out-wake': durable running wake count for an epoch.
|
|
91
|
+
* Lives in the lifecycle log (not gate stats) because gate runtime
|
|
92
|
+
* state dies with the process and max-wakes must not reset on restart. */
|
|
93
|
+
epochId?: string;
|
|
94
|
+
wakeCount?: number;
|
|
56
95
|
source?: string;
|
|
57
96
|
messageId?: string;
|
|
58
97
|
acknowledgment?: string;
|
|
@@ -438,7 +477,15 @@ export class ChannelRegistry {
|
|
|
438
477
|
/** Chronicle-projected desired lifecycle state, keyed by server + channel.
|
|
439
478
|
* Provenance is kept so reconcile can tell a pure default (nobody ever
|
|
440
479
|
* decided) from a real decision (agent-tool, invitation-declined, …). */
|
|
441
|
-
private desiredStates = new Map<string, {
|
|
480
|
+
private desiredStates = new Map<string, {
|
|
481
|
+
state: DesiredChannelState;
|
|
482
|
+
source: string;
|
|
483
|
+
/** Present iff state === 'tuned-out'. */
|
|
484
|
+
tuneOut?: TuneOutParams;
|
|
485
|
+
/** Durable wake count for the active tune-out epoch (replayed from
|
|
486
|
+
* 'tune-out-wake' lifecycle records; see recordTuneOutWake). */
|
|
487
|
+
wakeCount?: number;
|
|
488
|
+
}>();
|
|
442
489
|
|
|
443
490
|
/** One-time migration inputs from the retired recipe auto-open policy. */
|
|
444
491
|
private legacyPolicies = new Map<string, 'auto' | 'manual' | string[]>();
|
|
@@ -1146,12 +1193,31 @@ export class ChannelRegistry {
|
|
|
1146
1193
|
if (
|
|
1147
1194
|
event.kind === 'desired-state' &&
|
|
1148
1195
|
typeof event.channelId === 'string' &&
|
|
1149
|
-
(event.desired === 'open' || event.desired === 'closed'
|
|
1196
|
+
(event.desired === 'open' || event.desired === 'closed' ||
|
|
1197
|
+
(event.desired === 'tuned-out' && event.tuneOut))
|
|
1150
1198
|
) {
|
|
1151
1199
|
this.desiredStates.set(
|
|
1152
1200
|
this.lifecycleKey(event.serverId, event.channelId),
|
|
1153
|
-
{
|
|
1201
|
+
{
|
|
1202
|
+
state: event.desired,
|
|
1203
|
+
source: typeof event.source === "string" ? event.source : "unknown",
|
|
1204
|
+
tuneOut: event.desired === 'tuned-out' ? event.tuneOut : undefined,
|
|
1205
|
+
wakeCount: 0,
|
|
1206
|
+
},
|
|
1154
1207
|
);
|
|
1208
|
+
} else if (
|
|
1209
|
+
event.kind === 'tune-out-wake' &&
|
|
1210
|
+
typeof event.channelId === 'string' &&
|
|
1211
|
+
typeof event.wakeCount === 'number'
|
|
1212
|
+
) {
|
|
1213
|
+
// Fold durable wake counts into the projection — but only while the
|
|
1214
|
+
// epoch that recorded them is still the active desired state
|
|
1215
|
+
// (last-record-wins semantics, same as desired-state itself).
|
|
1216
|
+
const key = this.lifecycleKey(event.serverId, event.channelId);
|
|
1217
|
+
const current = this.desiredStates.get(key);
|
|
1218
|
+
if (current?.state === 'tuned-out' && current.tuneOut?.epochId === event.epochId) {
|
|
1219
|
+
current.wakeCount = event.wakeCount;
|
|
1220
|
+
}
|
|
1155
1221
|
} else if (event.kind === 'legacy-policy-migrated') {
|
|
1156
1222
|
this.migratedLegacyPolicies.add(event.serverId);
|
|
1157
1223
|
}
|
|
@@ -1181,6 +1247,147 @@ export class ChannelRegistry {
|
|
|
1181
1247
|
});
|
|
1182
1248
|
}
|
|
1183
1249
|
|
|
1250
|
+
// ==========================================================================
|
|
1251
|
+
// Tune-out state (issue #77) — durable in the lifecycle log
|
|
1252
|
+
// ==========================================================================
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* Enter (or re-enter with fresh params) the tuned-out state for a channel.
|
|
1256
|
+
* Re-entering under a new epochId replaces the active epoch; the previous
|
|
1257
|
+
* epoch's stamped messages stay excluded (stamps are permanent) and its
|
|
1258
|
+
* wake count is superseded. Transport stays open (see reconcile).
|
|
1259
|
+
*/
|
|
1260
|
+
enterTuneOut(
|
|
1261
|
+
serverId: string,
|
|
1262
|
+
channelId: string,
|
|
1263
|
+
params: TuneOutParams,
|
|
1264
|
+
source: string,
|
|
1265
|
+
): void {
|
|
1266
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1267
|
+
this.desiredStates.set(key, {
|
|
1268
|
+
state: 'tuned-out',
|
|
1269
|
+
source,
|
|
1270
|
+
tuneOut: params,
|
|
1271
|
+
wakeCount: 0,
|
|
1272
|
+
});
|
|
1273
|
+
this.appendLifecycleEvent({
|
|
1274
|
+
kind: 'desired-state',
|
|
1275
|
+
serverId,
|
|
1276
|
+
channelId,
|
|
1277
|
+
desired: 'tuned-out',
|
|
1278
|
+
tuneOut: params,
|
|
1279
|
+
source,
|
|
1280
|
+
timestamp: new Date().toISOString(),
|
|
1281
|
+
});
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* End the active tune-out, returning the channel to `nextState`.
|
|
1286
|
+
* The caller (tune-out coordinator) owns the dump/notice flow; this is
|
|
1287
|
+
* only the durable state flip. No-op returning null if the channel is
|
|
1288
|
+
* not tuned out.
|
|
1289
|
+
*/
|
|
1290
|
+
cancelTuneOut(
|
|
1291
|
+
serverId: string,
|
|
1292
|
+
channelId: string,
|
|
1293
|
+
nextState: 'open' | 'closed',
|
|
1294
|
+
source: string,
|
|
1295
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1296
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1297
|
+
const current = this.desiredStates.get(key);
|
|
1298
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1299
|
+
const ended = { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
|
|
1300
|
+
this.desiredStates.set(key, { state: nextState, source });
|
|
1301
|
+
this.appendLifecycleEvent({
|
|
1302
|
+
kind: 'desired-state',
|
|
1303
|
+
serverId,
|
|
1304
|
+
channelId,
|
|
1305
|
+
desired: nextState,
|
|
1306
|
+
source,
|
|
1307
|
+
timestamp: new Date().toISOString(),
|
|
1308
|
+
});
|
|
1309
|
+
return ended;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Durably record one wake invocation against the active epoch and return
|
|
1314
|
+
* the updated count with the params (the coordinator compares against
|
|
1315
|
+
* maxWakes and decides auto-cancel). Durable here, not in gate stats:
|
|
1316
|
+
* gate runtime state dies with the process, and a restart must not grant
|
|
1317
|
+
* a hammered channel a fresh wake budget.
|
|
1318
|
+
*/
|
|
1319
|
+
recordTuneOutWake(
|
|
1320
|
+
serverId: string,
|
|
1321
|
+
channelId: string,
|
|
1322
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1323
|
+
const key = this.lifecycleKey(serverId, channelId);
|
|
1324
|
+
const current = this.desiredStates.get(key);
|
|
1325
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1326
|
+
current.wakeCount = (current.wakeCount ?? 0) + 1;
|
|
1327
|
+
this.appendLifecycleEvent({
|
|
1328
|
+
kind: 'tune-out-wake',
|
|
1329
|
+
serverId,
|
|
1330
|
+
channelId,
|
|
1331
|
+
epochId: current.tuneOut.epochId,
|
|
1332
|
+
wakeCount: current.wakeCount,
|
|
1333
|
+
timestamp: new Date().toISOString(),
|
|
1334
|
+
});
|
|
1335
|
+
return { params: current.tuneOut, wakeCount: current.wakeCount };
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/** Active tune-out params + wake count for a channel, or null. */
|
|
1339
|
+
getTuneOutState(
|
|
1340
|
+
serverId: string,
|
|
1341
|
+
channelId: string,
|
|
1342
|
+
): { params: TuneOutParams; wakeCount: number } | null {
|
|
1343
|
+
const current = this.desiredStates.get(this.lifecycleKey(serverId, channelId));
|
|
1344
|
+
if (current?.state !== 'tuned-out' || !current.tuneOut) return null;
|
|
1345
|
+
return { params: current.tuneOut, wakeCount: current.wakeCount ?? 0 };
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Registered channel entries (read-only iteration for the coordinator). */
|
|
1349
|
+
listChannelsRaw(): Array<{ serverId: string; descriptor: ChannelDescriptor }> {
|
|
1350
|
+
return [...this.channels.values()].map((e) => ({
|
|
1351
|
+
serverId: e.serverId,
|
|
1352
|
+
descriptor: e.descriptor,
|
|
1353
|
+
}));
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* Small chronicle snapshot-state helpers for the tune-out coordinator
|
|
1358
|
+
* (dispositions slot). Registration is idempotent; absent store = null/no-op
|
|
1359
|
+
* (mirrors the lifecycle log's optional-store posture).
|
|
1360
|
+
*/
|
|
1361
|
+
readCoordinatorState(stateId: string): unknown {
|
|
1362
|
+
if (!this.store) return null;
|
|
1363
|
+
try {
|
|
1364
|
+
this.store.registerState({ id: stateId, strategy: 'snapshot' });
|
|
1365
|
+
} catch { /* already registered */ }
|
|
1366
|
+
return this.store.getStateJson(stateId);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
writeCoordinatorState(stateId: string, value: unknown): void {
|
|
1370
|
+
if (!this.store) return;
|
|
1371
|
+
try {
|
|
1372
|
+
this.store.registerState({ id: stateId, strategy: 'snapshot' });
|
|
1373
|
+
} catch { /* already registered */ }
|
|
1374
|
+
this.store.setStateJson(stateId, value);
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* Publish into a channel on behalf of a named non-resident agent (the
|
|
1379
|
+
* subconscious's speak_in_channel). Same delivery path as the
|
|
1380
|
+
* channel_publish tool; the agent name rides the speech-routed trace.
|
|
1381
|
+
*/
|
|
1382
|
+
async publishForAgent(
|
|
1383
|
+
channelId: string,
|
|
1384
|
+
text: string,
|
|
1385
|
+
agentName: string,
|
|
1386
|
+
): Promise<{ success: boolean; data?: unknown; error?: string; isError?: boolean }> {
|
|
1387
|
+
this.emitTraceFn({ type: 'mcpl:speech-routed', conversationId: agentName, channelId, text });
|
|
1388
|
+
return this.handleToolPublish({ channelId, content: text });
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1184
1391
|
/**
|
|
1185
1392
|
* Consume the old recipe policy exactly once. It seeds Chronicle for
|
|
1186
1393
|
* existing deployments, but is not an ongoing admission policy: channels
|
|
@@ -1297,7 +1504,10 @@ export class ChannelRegistry {
|
|
|
1297
1504
|
const key = `${serverId}:${channel.id}`;
|
|
1298
1505
|
const desired = this.getDesiredState(serverId, channel.id);
|
|
1299
1506
|
const entry = this.channels.get(key);
|
|
1300
|
-
|
|
1507
|
+
// Tuned-out sits on the OPEN side of reconcile: traffic must keep
|
|
1508
|
+
// arriving (the subconscious reads it); only main's wake/visibility
|
|
1509
|
+
// is diverted, downstream at ingestion.
|
|
1510
|
+
if (desired !== 'open' && desired !== 'tuned-out') {
|
|
1301
1511
|
try {
|
|
1302
1512
|
await server.sendChannelsClose({ channelId: channel.id });
|
|
1303
1513
|
if (entry) entry.open = false;
|
|
@@ -1710,6 +1920,22 @@ export class ChannelRegistry {
|
|
|
1710
1920
|
};
|
|
1711
1921
|
}
|
|
1712
1922
|
|
|
1923
|
+
// A tuned-out channel is transport-open but attention-diverted; a plain
|
|
1924
|
+
// open would silently discard the epoch (and its pending backlog dump).
|
|
1925
|
+
// Cancelling is the tune-out coordinator's flow, reached via its own
|
|
1926
|
+
// tool — refuse with the pointer rather than eat the state.
|
|
1927
|
+
if (this.getDesiredState(entry.serverId, input.channelId) === 'tuned-out') {
|
|
1928
|
+
return {
|
|
1929
|
+
success: false,
|
|
1930
|
+
isError: false,
|
|
1931
|
+
error:
|
|
1932
|
+
`Channel ${input.channelId} is tuned out. Cancel the tune-out ` +
|
|
1933
|
+
`(tune_out with mode: 'cancel') to resume normal attention — ` +
|
|
1934
|
+
`cancelling delivers the diverted backlog.`,
|
|
1935
|
+
data: { refusal: 'tuned-out', channelId: input.channelId },
|
|
1936
|
+
};
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1713
1939
|
const alreadyDesiredOpen = this.getDesiredState(entry.serverId, input.channelId) === 'open';
|
|
1714
1940
|
if (entry.open && alreadyDesiredOpen && !input.backscroll) {
|
|
1715
1941
|
return {
|
|
@@ -1808,7 +2034,9 @@ export class ChannelRegistry {
|
|
|
1808
2034
|
// than retry — unless it certifies an explicit idle lease.
|
|
1809
2035
|
if (isModuleOrigin && closeSource !== 'agent-tool' && !input.overrideExplicitOpen) {
|
|
1810
2036
|
const current = this.desiredStates.get(this.lifecycleKey(entry.serverId, input.channelId));
|
|
1811
|
-
|
|
2037
|
+
// Tuned-out is stated intent too: a GC/housekeeping close would
|
|
2038
|
+
// silently end the epoch and orphan its backlog.
|
|
2039
|
+
if ((current?.state === 'open' || current?.state === 'tuned-out') && current.source === 'agent-tool') {
|
|
1812
2040
|
return {
|
|
1813
2041
|
success: false,
|
|
1814
2042
|
isError: false,
|
|
@@ -369,9 +369,13 @@ function validateJpeg(bytes: Buffer, mountPrefixedPath: string): void {
|
|
|
369
369
|
offset += 1;
|
|
370
370
|
|
|
371
371
|
if (marker === JPEG_EOI) {
|
|
372
|
-
if (!sawSof || !sawSos
|
|
372
|
+
if (!sawSof || !sawSos) {
|
|
373
373
|
invalidImage('JPEG', mountPrefixedPath);
|
|
374
374
|
}
|
|
375
|
+
// Bytes after EOI are tolerated: hardware encoders (e.g. Raspberry Pi
|
|
376
|
+
// camera stills) pad each frame to a 4-byte boundary with NULs after
|
|
377
|
+
// the EOI marker, and every mainstream decoder stops at EOI. The image
|
|
378
|
+
// proper (SOI..EOI) has been fully validated by this point.
|
|
375
379
|
return;
|
|
376
380
|
}
|
|
377
381
|
if (marker === JPEG_TEM || (marker >= 0xd0 && marker <= 0xd7)) {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ContentBlock } from '@animalabs/membrane';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Recognize ONLY a whole-response textual invocation wrapper for a tool that
|
|
5
|
+
* was registered on this exact inference. This is containment, not parsing:
|
|
6
|
+
* callers must never execute the returned tool name or wrapper body.
|
|
7
|
+
*/
|
|
8
|
+
export function detectKnownToolWrapperProse(
|
|
9
|
+
content: ContentBlock[],
|
|
10
|
+
knownToolNames: ReadonlySet<string>,
|
|
11
|
+
): string | null {
|
|
12
|
+
if (knownToolNames.size === 0) return null;
|
|
13
|
+
|
|
14
|
+
// Signed thinking may precede visible prose. Any other non-text block makes
|
|
15
|
+
// this a mixed response, not an exact textual wrapper.
|
|
16
|
+
const text: string[] = [];
|
|
17
|
+
for (const block of content) {
|
|
18
|
+
if (block.type === 'text') text.push(block.text);
|
|
19
|
+
else if (block.type !== 'thinking' && block.type !== 'redacted_thinking') return null;
|
|
20
|
+
}
|
|
21
|
+
const body = text.join('\n').trim();
|
|
22
|
+
if (!body) return null;
|
|
23
|
+
|
|
24
|
+
const tagName = '[A-Za-z_][A-Za-z0-9_.:-]*';
|
|
25
|
+
let match = body.match(new RegExp(`^<(${tagName})\\s*/>$`));
|
|
26
|
+
if (match && knownToolNames.has(match[1]!)) return match[1]!;
|
|
27
|
+
|
|
28
|
+
match = body.match(new RegExp(`^<(${tagName})\\s*>([\\s\\S]*?)<\\/\\1\\s*>$`));
|
|
29
|
+
if (match && knownToolNames.has(match[1]!)) return match[1]!;
|
|
30
|
+
|
|
31
|
+
// Legacy XML formatter shape used by older Claude harnesses.
|
|
32
|
+
match = body.match(/^<invoke\s*>\s*<tool\s+name=(['"])([^'"]+)\1(?:\s+[^>]*)?\s*\/>\s*<\/invoke\s*>$/);
|
|
33
|
+
if (match && knownToolNames.has(match[2]!)) return match[2]!;
|
|
34
|
+
match = body.match(/^<invoke\s*>\s*<tool\s+name=(['"])([^'"]+)\1(?:\s+[^>]*)?\s*>([\s\S]*?)<\/tool\s*>\s*<\/invoke\s*>$/);
|
|
35
|
+
if (match && knownToolNames.has(match[2]!)) return match[2]!;
|
|
36
|
+
|
|
37
|
+
return null;
|
|
38
|
+
}
|