@animalabs/agent-framework 0.9.0 → 0.11.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 +1 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +72 -4
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +699 -48
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +62 -7
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +333 -22
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +56 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.d.ts +11 -0
- package/dist/src/mcpl/prose-grammar.d.ts.map +1 -1
- package/dist/src/mcpl/prose-grammar.js +21 -0
- package/dist/src/mcpl/prose-grammar.js.map +1 -1
- package/dist/src/mcpl/prose-stream-router.d.ts +2 -2
- package/dist/src/mcpl/prose-stream-router.d.ts.map +1 -1
- package/dist/src/mcpl/prose-stream-router.js +57 -13
- package/dist/src/mcpl/prose-stream-router.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +21 -5
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/workspace/index.d.ts +23 -0
- package/dist/src/modules/workspace/index.d.ts.map +1 -1
- package/dist/src/modules/workspace/index.js +53 -3
- package/dist/src/modules/workspace/index.js.map +1 -1
- package/dist/src/modules/workspace/sync.d.ts +11 -2
- package/dist/src/modules/workspace/sync.d.ts.map +1 -1
- package/dist/src/modules/workspace/sync.js +27 -11
- package/dist/src/modules/workspace/sync.js.map +1 -1
- package/dist/src/modules/workspace/watcher.d.ts.map +1 -1
- package/dist/src/modules/workspace/watcher.js +9 -6
- package/dist/src/modules/workspace/watcher.js.map +1 -1
- package/dist/src/queue.d.ts.map +1 -1
- package/dist/src/queue.js +9 -2
- package/dist/src/queue.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +12 -2
- package/dist/src/tool-result-history.d.ts.map +1 -1
- package/dist/src/tool-result-history.js +12 -2
- package/dist/src/tool-result-history.js.map +1 -1
- package/dist/src/types/agent.d.ts +6 -1
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +1 -1
- package/dist/src/types/trace.d.ts +7 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/busy-agent-scheduler.test.js +29 -0
- package/dist/test/busy-agent-scheduler.test.js.map +1 -1
- package/dist/test/discord-history-delete-window.test.d.ts +2 -0
- package/dist/test/discord-history-delete-window.test.d.ts.map +1 -0
- package/dist/test/discord-history-delete-window.test.js +81 -0
- package/dist/test/discord-history-delete-window.test.js.map +1 -0
- package/dist/test/discord-history-sync.test.d.ts +2 -0
- package/dist/test/discord-history-sync.test.d.ts.map +1 -0
- package/dist/test/discord-history-sync.test.js +56 -0
- package/dist/test/discord-history-sync.test.js.map +1 -0
- package/dist/test/event-gate-passthrough.test.d.ts +15 -0
- package/dist/test/event-gate-passthrough.test.d.ts.map +1 -0
- package/dist/test/event-gate-passthrough.test.js +289 -0
- package/dist/test/event-gate-passthrough.test.js.map +1 -0
- package/dist/test/explicit-prose-routing.test.js +179 -7
- package/dist/test/explicit-prose-routing.test.js.map +1 -1
- package/dist/test/framework.test.js +33 -0
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/prose-stream-router.test.js +43 -0
- package/dist/test/prose-stream-router.test.js.map +1 -1
- package/dist/test/provider-acceleration-cooldown.test.d.ts +2 -0
- package/dist/test/provider-acceleration-cooldown.test.d.ts.map +1 -0
- package/dist/test/provider-acceleration-cooldown.test.js +286 -0
- package/dist/test/provider-acceleration-cooldown.test.js.map +1 -0
- package/dist/test/puppet-tool-call.test.d.ts +2 -0
- package/dist/test/puppet-tool-call.test.d.ts.map +1 -0
- package/dist/test/puppet-tool-call.test.js +106 -0
- package/dist/test/puppet-tool-call.test.js.map +1 -0
- package/dist/test/release-changelog.test.d.ts +2 -0
- package/dist/test/release-changelog.test.d.ts.map +1 -0
- package/dist/test/release-changelog.test.js +159 -0
- package/dist/test/release-changelog.test.js.map +1 -0
- package/dist/test/tool-result-spill.test.d.ts +1 -1
- package/dist/test/tool-result-spill.test.js +18 -9
- package/dist/test/tool-result-spill.test.js.map +1 -1
- package/dist/test/workspace-path-containment.test.d.ts +19 -0
- package/dist/test/workspace-path-containment.test.d.ts.map +1 -0
- package/dist/test/workspace-path-containment.test.js +91 -0
- package/dist/test/workspace-path-containment.test.js.map +1 -0
- package/dist/test/workspace-watcher.test.js +15 -3
- package/dist/test/workspace-watcher.test.js.map +1 -1
- package/dist/test/xml-round-persist.test.d.ts +19 -0
- package/dist/test/xml-round-persist.test.d.ts.map +1 -0
- package/dist/test/xml-round-persist.test.js +235 -0
- package/dist/test/xml-round-persist.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/agent.ts +1 -1
- package/src/framework.ts +733 -48
- package/src/gate/event-gate.ts +355 -24
- package/src/gate/types.ts +58 -0
- package/src/mcpl/prose-grammar.ts +21 -0
- package/src/mcpl/prose-stream-router.ts +48 -14
- package/src/modules/discord/index.ts +24 -6
- package/src/modules/workspace/index.ts +53 -3
- package/src/modules/workspace/sync.ts +39 -10
- package/src/modules/workspace/watcher.ts +9 -6
- package/src/queue.ts +8 -2
- package/src/tool-result-history.ts +12 -2
- package/src/types/agent.ts +6 -1
- package/src/types/framework.ts +1 -1
- package/src/types/trace.ts +13 -0
package/src/framework.ts
CHANGED
|
@@ -62,7 +62,7 @@ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './m
|
|
|
62
62
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
63
63
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
64
64
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
65
|
-
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
65
|
+
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
66
66
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
67
67
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
68
68
|
import { ChannelRegistry, type ChannelToolOrigin } from './mcpl/channel-registry.js';
|
|
@@ -126,6 +126,9 @@ const SILENCING_TOOLS = new Set([
|
|
|
126
126
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
127
127
|
]);
|
|
128
128
|
|
|
129
|
+
/** World-surface publication names that outrank hybrid prose envelopes. */
|
|
130
|
+
const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
131
|
+
|
|
129
132
|
/**
|
|
130
133
|
* True when an injected message is real conversational input — something the
|
|
131
134
|
* agent might actually be replying to — rather than ambient machinery.
|
|
@@ -225,11 +228,18 @@ const isAddressedMessage = (
|
|
|
225
228
|
// envelopes can never disagree on what is a prefix.
|
|
226
229
|
|
|
227
230
|
/** One-time primer appended when an agent's proseRouting mode changes. */
|
|
228
|
-
function proseModePrimer(mode: 'explicit' | 'locus'): string {
|
|
231
|
+
function proseModePrimer(mode: 'explicit' | 'hybrid' | 'locus' | 'disabled'): string {
|
|
229
232
|
if (mode === 'locus') {
|
|
230
233
|
return '[prose-routing] Mode change: your plain text auto-routes to the ' +
|
|
231
234
|
'conversational locus again. `>>` destination prefixes are no longer needed.';
|
|
232
235
|
}
|
|
236
|
+
if (mode === 'hybrid') {
|
|
237
|
+
return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
|
|
238
|
+
'current conversational locus. A leading `>>>destination` envelope instead routes ' +
|
|
239
|
+
'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
|
|
240
|
+
'envelope remains in your memory but recipients see only its body; delivery or failure ' +
|
|
241
|
+
'is reported back to you.';
|
|
242
|
+
}
|
|
233
243
|
// Deliberately terse: a short event-style notice is classifier-safe from
|
|
234
244
|
// the user role (ablation D, 2026-07-24), while the full grammar as a user
|
|
235
245
|
// message drew a deterministic reasoning_extraction refusal (ablation A/B).
|
|
@@ -329,7 +339,7 @@ function validateUtilityArgs(
|
|
|
329
339
|
}
|
|
330
340
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
331
341
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
332
|
-
import { EventGate } from './gate/event-gate.js';
|
|
342
|
+
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
333
343
|
import { UsageTracker, type PersistedUsageState } from './usage/usage-tracker.js';
|
|
334
344
|
import type { SessionUsageSnapshot, UsageUpdatedEvent } from './usage/types.js';
|
|
335
345
|
import type { McplServerConnection } from './mcpl/server-connection.js';
|
|
@@ -465,6 +475,53 @@ const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
|
|
|
465
475
|
/** Bound one pass so a large backlog yields to inference and other agents. */
|
|
466
476
|
const MAINTENANCE_TICKS_PER_PASS = 8;
|
|
467
477
|
|
|
478
|
+
/** Local per-residence provider admission (AF #114 bounded first slice). */
|
|
479
|
+
interface LocalProviderGate {
|
|
480
|
+
primaryDepth: number;
|
|
481
|
+
primaryPending: boolean;
|
|
482
|
+
auxiliaryInFlight: number;
|
|
483
|
+
auxiliaryWaiters: Array<() => void>;
|
|
484
|
+
idleWaiters: Array<() => void>;
|
|
485
|
+
deferredAuxiliary: number;
|
|
486
|
+
}
|
|
487
|
+
interface ProviderAccelerationCooldown {
|
|
488
|
+
startedAt: number;
|
|
489
|
+
until: number;
|
|
490
|
+
timer: ReturnType<typeof setTimeout>;
|
|
491
|
+
heldRequests: InferenceRequest[];
|
|
492
|
+
reason: string;
|
|
493
|
+
failures: number;
|
|
494
|
+
}
|
|
495
|
+
interface ProviderAccelerationRecovery {
|
|
496
|
+
startedAt: number;
|
|
497
|
+
releasedAt?: number;
|
|
498
|
+
failures: number;
|
|
499
|
+
heldRequests: number;
|
|
500
|
+
reason: string;
|
|
501
|
+
}
|
|
502
|
+
interface ProviderAccelerationReceipt {
|
|
503
|
+
startedAt: number;
|
|
504
|
+
releasedAt: number;
|
|
505
|
+
completedAt: number;
|
|
506
|
+
cooldownMs: number;
|
|
507
|
+
waitedMs: number;
|
|
508
|
+
failures: number;
|
|
509
|
+
heldRequests: number;
|
|
510
|
+
messageCount: number;
|
|
511
|
+
toolCount: number;
|
|
512
|
+
stopReason: string;
|
|
513
|
+
}
|
|
514
|
+
const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
|
|
515
|
+
const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
|
|
516
|
+
const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
|
|
517
|
+
function isOrganizationAccelerationRateLimit(error: Error): error is MembraneError {
|
|
518
|
+
if (!(error instanceof MembraneError) || error.type !== 'rate_limit') return false;
|
|
519
|
+
return (
|
|
520
|
+
/organization(?:'s)?[^\n]{0,120}maximum(?:\s+usage)?\s+increase\s+rate/i.test(error.message) ||
|
|
521
|
+
/organization(?:'s)?[^\n]{0,120}acceleration(?:\s+limit)?/i.test(error.message)
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
468
525
|
/**
|
|
469
526
|
* Extract fields the EventGate cares about from a ProcessEvent. The set of
|
|
470
527
|
* event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
|
|
@@ -613,6 +670,13 @@ export class AgentFramework {
|
|
|
613
670
|
private maintenanceRunId = 0;
|
|
614
671
|
private currentMaintenanceRun: ContextMaintenanceRun | null = null;
|
|
615
672
|
private maintenanceHistory: ContextMaintenanceRun[] = [];
|
|
673
|
+
private providerGates: Map<string, LocalProviderGate> = new Map();
|
|
674
|
+
private providerAccelerationCooldowns: Map<string, ProviderAccelerationCooldown> = new Map();
|
|
675
|
+
private providerAccelerationRecoveries: Map<string, ProviderAccelerationRecovery> = new Map();
|
|
676
|
+
private providerAccelerationLastRecovery: Map<string, ProviderAccelerationReceipt> = new Map();
|
|
677
|
+
private providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
|
|
678
|
+
private providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
|
|
679
|
+
private providerAdmissionClosed = false;
|
|
616
680
|
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
617
681
|
private staleWarnAt = new Map<string, number>();
|
|
618
682
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
@@ -684,6 +748,8 @@ export class AgentFramework {
|
|
|
684
748
|
/** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
|
|
685
749
|
* Cleared at every non-restart turn start; survives context restarts. */
|
|
686
750
|
private proseTargetPins: Map<string, string> = new Map();
|
|
751
|
+
/** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
|
|
752
|
+
private proseHybridSuppressed: Set<string> = new Set();
|
|
687
753
|
/** Agents whose current turn requested `!` continuation — re-woken when the
|
|
688
754
|
* turn completes instead of pausing until the next external event. */
|
|
689
755
|
private proseContinuations: Set<string> = new Set();
|
|
@@ -1185,6 +1251,7 @@ export class AgentFramework {
|
|
|
1185
1251
|
}
|
|
1186
1252
|
|
|
1187
1253
|
this.running = true;
|
|
1254
|
+
this.providerAdmissionClosed = false;
|
|
1188
1255
|
this.loopPromise = this.runLoop();
|
|
1189
1256
|
|
|
1190
1257
|
// Start periodic sync timer (if enabled)
|
|
@@ -1214,6 +1281,7 @@ export class AgentFramework {
|
|
|
1214
1281
|
*/
|
|
1215
1282
|
async stop(): Promise<void> {
|
|
1216
1283
|
this.running = false;
|
|
1284
|
+
this.providerAdmissionClosed = true;
|
|
1217
1285
|
this.queue.close();
|
|
1218
1286
|
|
|
1219
1287
|
// Kill running code_execution scripts before cancelling streams: a
|
|
@@ -1235,6 +1303,9 @@ export class AgentFramework {
|
|
|
1235
1303
|
}
|
|
1236
1304
|
this.backgroundScripts.clear();
|
|
1237
1305
|
|
|
1306
|
+
// A stopped host must never hang behind its own cooldown.
|
|
1307
|
+
this.cancelProviderAdmission();
|
|
1308
|
+
|
|
1238
1309
|
// Cancel all active streams
|
|
1239
1310
|
for (const agent of this.agents.values()) {
|
|
1240
1311
|
if (agent.state.status === 'streaming' ||
|
|
@@ -1300,6 +1371,138 @@ export class AgentFramework {
|
|
|
1300
1371
|
* schemas are absent. Passes never overlap; each agent gets a bounded drain
|
|
1301
1372
|
* so maintenance cannot monopolize the framework event loop.
|
|
1302
1373
|
*/
|
|
1374
|
+
private providerGate(agentName: string): LocalProviderGate {
|
|
1375
|
+
let gate = this.providerGates.get(agentName);
|
|
1376
|
+
if (!gate) {
|
|
1377
|
+
gate = { primaryDepth: 0, primaryPending: false, auxiliaryInFlight: 0,
|
|
1378
|
+
auxiliaryWaiters: [], idleWaiters: [], deferredAuxiliary: 0 };
|
|
1379
|
+
this.providerGates.set(agentName, gate);
|
|
1380
|
+
}
|
|
1381
|
+
return gate;
|
|
1382
|
+
}
|
|
1383
|
+
private providerGateBlocked(agentName: string): boolean {
|
|
1384
|
+
const gate = this.providerGates.get(agentName);
|
|
1385
|
+
return (gate?.primaryDepth ?? 0) > 0 || (gate?.primaryPending ?? false) ||
|
|
1386
|
+
this.providerAccelerationCooldowns.has(agentName);
|
|
1387
|
+
}
|
|
1388
|
+
private acquirePrimaryProviderGate(agentName: string): void {
|
|
1389
|
+
const gate = this.providerGate(agentName); gate.primaryPending = false; gate.primaryDepth++;
|
|
1390
|
+
}
|
|
1391
|
+
private releasePrimaryProviderGate(agentName: string): void {
|
|
1392
|
+
const gate = this.providerGate(agentName); gate.primaryDepth = Math.max(0, gate.primaryDepth - 1);
|
|
1393
|
+
this.flushAuxiliaryAdmission(agentName);
|
|
1394
|
+
}
|
|
1395
|
+
private flushAuxiliaryAdmission(agentName: string): void {
|
|
1396
|
+
const gate = this.providerGate(agentName);
|
|
1397
|
+
if (gate.primaryDepth > 0 || gate.primaryPending || this.providerAccelerationCooldowns.has(agentName)) return;
|
|
1398
|
+
for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve();
|
|
1399
|
+
}
|
|
1400
|
+
private async waitForAuxiliaryIdle(agentName: string): Promise<void> {
|
|
1401
|
+
const gate = this.providerGate(agentName);
|
|
1402
|
+
if (gate.auxiliaryInFlight === 0) return;
|
|
1403
|
+
await new Promise<void>((resolve) => gate.idleWaiters.push(resolve));
|
|
1404
|
+
}
|
|
1405
|
+
private async withAuxiliaryAdmission<T>(agentName: string, run: () => Promise<T>): Promise<T> {
|
|
1406
|
+
const gate = this.providerGate(agentName);
|
|
1407
|
+
if (this.providerGateBlocked(agentName)) {
|
|
1408
|
+
gate.deferredAuxiliary++;
|
|
1409
|
+
try {
|
|
1410
|
+
await new Promise<void>((resolve) => gate.auxiliaryWaiters.push(resolve));
|
|
1411
|
+
} finally {
|
|
1412
|
+
gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
if (this.providerAdmissionClosed) {
|
|
1416
|
+
throw new Error(`Provider admission closed while stopping (${agentName})`);
|
|
1417
|
+
}
|
|
1418
|
+
gate.auxiliaryInFlight++;
|
|
1419
|
+
try { return await run(); }
|
|
1420
|
+
finally {
|
|
1421
|
+
gate.auxiliaryInFlight = Math.max(0, gate.auxiliaryInFlight - 1);
|
|
1422
|
+
if (gate.auxiliaryInFlight === 0) for (const resolve of gate.idleWaiters.splice(0)) resolve();
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
private auxiliaryMembraneFor(agentName: string): Membrane {
|
|
1426
|
+
const target = this.membrane as unknown as Record<PropertyKey, unknown>;
|
|
1427
|
+
return new Proxy(target, { get: (obj, prop, receiver) => {
|
|
1428
|
+
const value = Reflect.get(obj, prop, receiver);
|
|
1429
|
+
if (prop === 'complete' && typeof value === 'function') {
|
|
1430
|
+
return (...args: unknown[]) => this.withAuxiliaryAdmission(
|
|
1431
|
+
agentName, () => Reflect.apply(value, this.membrane, args) as Promise<unknown>);
|
|
1432
|
+
}
|
|
1433
|
+
return typeof value === 'function' ? value.bind(this.membrane) : value;
|
|
1434
|
+
}}) as unknown as Membrane;
|
|
1435
|
+
}
|
|
1436
|
+
private accelerationCooldownMs(agentName: string, error: MembraneError): number {
|
|
1437
|
+
const base = error.retryAfterMs ?? this.providerAccelerationDefaultCooldownMs;
|
|
1438
|
+
let hash = 0; for (const ch of agentName) hash = ((hash * 31) + ch.charCodeAt(0)) >>> 0;
|
|
1439
|
+
const jitter = this.providerAccelerationJitterMs > 0 ? hash % (this.providerAccelerationJitterMs + 1) : 0;
|
|
1440
|
+
return Math.min(PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, Math.max(1_000, base) + jitter);
|
|
1441
|
+
}
|
|
1442
|
+
private sameInferenceRequest(a: InferenceRequest, b: InferenceRequest): boolean {
|
|
1443
|
+
return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source &&
|
|
1444
|
+
a.timestamp === b.timestamp && a.channelId === b.channelId;
|
|
1445
|
+
}
|
|
1446
|
+
private holdProviderAcceleration(agent: Agent, error: Error, trigger?: InferenceRequest): boolean {
|
|
1447
|
+
if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name) || !isOrganizationAccelerationRateLimit(error)) return false;
|
|
1448
|
+
const now = Date.now(); const delayMs = this.accelerationCooldownMs(agent.name, error);
|
|
1449
|
+
const existing = this.providerAccelerationCooldowns.get(agent.name);
|
|
1450
|
+
const held = existing?.heldRequests ?? [];
|
|
1451
|
+
if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger))) held.push(trigger);
|
|
1452
|
+
if (existing) clearTimeout(existing.timer);
|
|
1453
|
+
const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), delayMs); timer.unref?.();
|
|
1454
|
+
this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
|
|
1455
|
+
until: now + delayMs, timer, heldRequests: held, reason: error.message,
|
|
1456
|
+
failures: (existing?.failures ?? 0) + 1 });
|
|
1457
|
+
const gate = this.providerGate(agent.name); gate.primaryPending = true;
|
|
1458
|
+
this.providerAccelerationRecoveries.set(agent.name, { startedAt: existing?.startedAt ?? now,
|
|
1459
|
+
failures: (existing?.failures ?? 0) + 1, heldRequests: held.length, reason: error.message });
|
|
1460
|
+
console.error(`[provider-cooldown] agent=${agent.name} organization acceleration 429 — ` +
|
|
1461
|
+
`holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
|
|
1462
|
+
return true;
|
|
1463
|
+
}
|
|
1464
|
+
private releaseProviderAccelerationCooldown(agentName: string): void {
|
|
1465
|
+
const cooldown = this.providerAccelerationCooldowns.get(agentName); if (!cooldown) return;
|
|
1466
|
+
clearTimeout(cooldown.timer); this.providerAccelerationCooldowns.delete(agentName);
|
|
1467
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
1468
|
+
if (recovery) { recovery.releasedAt = Date.now(); recovery.heldRequests = cooldown.heldRequests.length; }
|
|
1469
|
+
const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
|
|
1470
|
+
reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() } satisfies InferenceRequest];
|
|
1471
|
+
this.pendingRequests.push(...requests);
|
|
1472
|
+
console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
|
|
1473
|
+
`fresh compile queued with ${requests.length} retained request(s)`);
|
|
1474
|
+
}
|
|
1475
|
+
private recordProviderAccelerationRecovery(agent: Agent, request: NormalizedRequest | undefined, stopReason: string): void {
|
|
1476
|
+
const recovery = this.providerAccelerationRecoveries.get(agent.name); if (!recovery?.releasedAt) return;
|
|
1477
|
+
this.providerAccelerationRecoveries.delete(agent.name);
|
|
1478
|
+
const completedAt = Date.now();
|
|
1479
|
+
const receipt: ProviderAccelerationReceipt = {
|
|
1480
|
+
startedAt: recovery.startedAt,
|
|
1481
|
+
releasedAt: recovery.releasedAt,
|
|
1482
|
+
completedAt,
|
|
1483
|
+
cooldownMs: recovery.releasedAt - recovery.startedAt,
|
|
1484
|
+
waitedMs: completedAt - recovery.startedAt,
|
|
1485
|
+
failures: recovery.failures,
|
|
1486
|
+
heldRequests: recovery.heldRequests,
|
|
1487
|
+
messageCount: request?.messages.length ?? 0,
|
|
1488
|
+
toolCount: request?.tools?.length ?? 0,
|
|
1489
|
+
stopReason,
|
|
1490
|
+
};
|
|
1491
|
+
this.providerAccelerationLastRecovery.set(agent.name, receipt);
|
|
1492
|
+
// Operational receipt only: provider admission must never author resident memory.
|
|
1493
|
+
console.error(`[provider-cooldown] recovered ${JSON.stringify({ agent: agent.name, ...receipt })}`);
|
|
1494
|
+
}
|
|
1495
|
+
private cancelProviderAdmission(): void {
|
|
1496
|
+
for (const cooldown of this.providerAccelerationCooldowns.values()) clearTimeout(cooldown.timer);
|
|
1497
|
+
this.providerAccelerationCooldowns.clear(); this.providerAccelerationRecoveries.clear();
|
|
1498
|
+
for (const gate of this.providerGates.values()) {
|
|
1499
|
+
gate.primaryDepth = 0; gate.primaryPending = false;
|
|
1500
|
+
for (const resolve of gate.auxiliaryWaiters.splice(0)) resolve();
|
|
1501
|
+
for (const resolve of gate.idleWaiters.splice(0)) resolve();
|
|
1502
|
+
}
|
|
1503
|
+
this.providerGates.clear();
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1303
1506
|
private startQueuedMaintenance(): void {
|
|
1304
1507
|
if (this.maintenancePass || !this.running) return;
|
|
1305
1508
|
const pass = this.runQueuedMaintenance().catch((error) => {
|
|
@@ -1326,6 +1529,7 @@ export class AgentFramework {
|
|
|
1326
1529
|
const cm = agent.getContextManager();
|
|
1327
1530
|
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
1328
1531
|
cm.setToolDefinitions(tools);
|
|
1532
|
+
if (this.providerGateBlocked(agent.name)) return [];
|
|
1329
1533
|
if (cm.isReady()) return [];
|
|
1330
1534
|
const pending = cm.getPendingWork()?.description;
|
|
1331
1535
|
const progress = this.contextProgress(cm);
|
|
@@ -1968,12 +2172,14 @@ export class AgentFramework {
|
|
|
1968
2172
|
/**
|
|
1969
2173
|
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
|
|
1970
2174
|
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
|
|
1971
|
-
* policy is invalid.
|
|
1972
|
-
*
|
|
2175
|
+
* policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
|
|
2176
|
+
* (including addressed-message wake rules — see EventGate.lintShadows);
|
|
2177
|
+
* `{before: name}` / `{after: name}` anchor it against a specific rule,
|
|
2178
|
+
* which is usually what a sampler or debounce actually wants.
|
|
1973
2179
|
*/
|
|
1974
2180
|
addGatePolicy(
|
|
1975
2181
|
rawPolicy: unknown,
|
|
1976
|
-
options?: { position?: 'append' | 'prepend' },
|
|
2182
|
+
options?: { position?: 'append' | 'prepend' | { before: string } | { after: string } },
|
|
1977
2183
|
): import('./gate/types.js').GatePolicy {
|
|
1978
2184
|
if (!this.eventGate) {
|
|
1979
2185
|
throw new Error('No EventGate configured (FrameworkConfig.gate is unset).');
|
|
@@ -2350,9 +2556,17 @@ export class AgentFramework {
|
|
|
2350
2556
|
description:
|
|
2351
2557
|
'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
|
|
2352
2558
|
'hand-edit the file. The rule is validated and hot-applied immediately. ' +
|
|
2353
|
-
'A rule with the same `name` replaces the existing one in place
|
|
2354
|
-
'
|
|
2355
|
-
'
|
|
2559
|
+
'A rule with the same `name` replaces the existing one in place (add a ' +
|
|
2560
|
+
'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
|
|
2561
|
+
'matched rule CONSUMES the event — a broad rule placed early makes every ' +
|
|
2562
|
+
'later rule unreachable for its scope, including your addressed-message ' +
|
|
2563
|
+
'(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
|
|
2564
|
+
'rule relative to a specific existing rule; use `position: "prepend"` only ' +
|
|
2565
|
+
'when the rule really must beat EVERYTHING. For "additionally wake me ' +
|
|
2566
|
+
'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
|
|
2567
|
+
'so non-firing matches fall through to your normal rules instead of ' +
|
|
2568
|
+
'swallowing them. The result includes a before/after probe table and any ' +
|
|
2569
|
+
'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
|
|
2356
2570
|
'• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
|
|
2357
2571
|
'`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
|
|
2358
2572
|
'• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
|
|
@@ -2420,7 +2634,30 @@ export class AgentFramework {
|
|
|
2420
2634
|
position: {
|
|
2421
2635
|
type: 'string',
|
|
2422
2636
|
enum: ['append', 'prepend'],
|
|
2423
|
-
description:
|
|
2637
|
+
description:
|
|
2638
|
+
'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
|
|
2639
|
+
'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
|
|
2640
|
+
'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
|
|
2641
|
+
'When replacing an existing name, giving any placement MOVES the rule there; ' +
|
|
2642
|
+
'omit all placement fields to update it in place.',
|
|
2643
|
+
},
|
|
2644
|
+
insertBefore: {
|
|
2645
|
+
type: 'string',
|
|
2646
|
+
description:
|
|
2647
|
+
'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
|
|
2648
|
+
'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
|
|
2649
|
+
},
|
|
2650
|
+
insertAfter: {
|
|
2651
|
+
type: 'string',
|
|
2652
|
+
description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
|
|
2653
|
+
},
|
|
2654
|
+
passthrough: {
|
|
2655
|
+
type: 'boolean',
|
|
2656
|
+
description:
|
|
2657
|
+
'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
|
|
2658
|
+
'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
|
|
2659
|
+
'instead of being consumed. Use for "additionally wake me every Nth event" ' +
|
|
2660
|
+
'governors so they never swallow DM/mention wakes.',
|
|
2424
2661
|
},
|
|
2425
2662
|
resets: {
|
|
2426
2663
|
type: 'array',
|
|
@@ -3693,7 +3930,9 @@ export class AgentFramework {
|
|
|
3693
3930
|
store: this.store,
|
|
3694
3931
|
namespace: `agents/${config.name}`,
|
|
3695
3932
|
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
3696
|
-
|
|
3933
|
+
// Context Manager complete() calls are auxiliary work; the primary
|
|
3934
|
+
// streaming agent keeps the original Membrane.
|
|
3935
|
+
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3697
3936
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3698
3937
|
});
|
|
3699
3938
|
|
|
@@ -3948,7 +4187,7 @@ export class AgentFramework {
|
|
|
3948
4187
|
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3949
4188
|
// diffs against what the agent was actually last told.
|
|
3950
4189
|
if (
|
|
3951
|
-
agent.proseRouting
|
|
4190
|
+
(agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
|
|
3952
4191
|
!shouldEndTurn && !overBudget && currentState.stream
|
|
3953
4192
|
) {
|
|
3954
4193
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
@@ -4417,6 +4656,9 @@ export class AgentFramework {
|
|
|
4417
4656
|
isolate: true,
|
|
4418
4657
|
// Strategy instances are stateful — never share the template's.
|
|
4419
4658
|
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
4659
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
4660
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
4661
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
4420
4662
|
membrane: this.membrane,
|
|
4421
4663
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
4422
4664
|
});
|
|
@@ -4800,7 +5042,8 @@ export class AgentFramework {
|
|
|
4800
5042
|
this.pendingRequests = [];
|
|
4801
5043
|
|
|
4802
5044
|
// Check each agent
|
|
4803
|
-
for (const [agentName,
|
|
5045
|
+
for (const [agentName, groupedRequests] of requestsByAgent) {
|
|
5046
|
+
let requests = groupedRequests;
|
|
4804
5047
|
const agent = this.agents.get(agentName);
|
|
4805
5048
|
if (!agent) {
|
|
4806
5049
|
// Agent not found — request is orphaned. Emit warning and drop.
|
|
@@ -4816,6 +5059,28 @@ export class AgentFramework {
|
|
|
4816
5059
|
continue;
|
|
4817
5060
|
}
|
|
4818
5061
|
|
|
5062
|
+
const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
|
|
5063
|
+
if (providerCooldown) {
|
|
5064
|
+
if (now < providerCooldown.until) {
|
|
5065
|
+
for (const req of requests) {
|
|
5066
|
+
if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
|
|
5067
|
+
providerCooldown.heldRequests.push(req);
|
|
5068
|
+
}
|
|
5069
|
+
}
|
|
5070
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
5071
|
+
if (recovery) recovery.heldRequests = providerCooldown.heldRequests.length;
|
|
5072
|
+
// The timer owns release. Do not hot-poll these requests.
|
|
5073
|
+
continue;
|
|
5074
|
+
}
|
|
5075
|
+
// Timers can deliver late under load. Release synchronously and merge
|
|
5076
|
+
// the held causes into this one current compile.
|
|
5077
|
+
clearTimeout(providerCooldown.timer);
|
|
5078
|
+
this.providerAccelerationCooldowns.delete(agentName);
|
|
5079
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
5080
|
+
if (recovery) recovery.releasedAt = now;
|
|
5081
|
+
requests = [...providerCooldown.heldRequests, ...requests];
|
|
5082
|
+
}
|
|
5083
|
+
|
|
4819
5084
|
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
4820
5085
|
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
4821
5086
|
// goes idle at endTurn's reset while driveStream teardown is still
|
|
@@ -4831,9 +5096,14 @@ export class AgentFramework {
|
|
|
4831
5096
|
// token; the predecessor frame's late finally no-ops via token-match.
|
|
4832
5097
|
// This mirrors how the restart has always overwritten activeStreams
|
|
4833
5098
|
// rather than waiting for the old stream's teardown.
|
|
4834
|
-
const
|
|
4835
|
-
const turnAlive = !
|
|
4836
|
-
|
|
5099
|
+
const budgetRestart = requests.find((r) => r.reason === 'context_budget_restart');
|
|
5100
|
+
const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
|
|
5101
|
+
const providerGate = this.providerGates?.get(agentName);
|
|
5102
|
+
// A primary can own provider admission while yielding to an already in-flight
|
|
5103
|
+
// auxiliary call. Keep this agent's later wakes queued, but do not block the
|
|
5104
|
+
// framework event loop or other residents while that auxiliary call settles.
|
|
5105
|
+
const providerPrimaryWaiting = (providerGate?.primaryDepth ?? 0) > 0 && !this.activeTurnTokens.has(agentName);
|
|
5106
|
+
if (providerPrimaryWaiting || turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
4837
5107
|
// Re-queue requests, but warn if they've been pending too long
|
|
4838
5108
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
4839
5109
|
if (
|
|
@@ -4877,11 +5147,22 @@ export class AgentFramework {
|
|
|
4877
5147
|
`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
4878
5148
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`,
|
|
4879
5149
|
);
|
|
5150
|
+
const gate = this.providerGates.get(agentName);
|
|
5151
|
+
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
5152
|
+
gate.primaryPending = false;
|
|
5153
|
+
this.providerAccelerationRecoveries.delete(agentName);
|
|
5154
|
+
this.flushAuxiliaryAdmission(agentName);
|
|
5155
|
+
}
|
|
4880
5156
|
continue;
|
|
4881
5157
|
}
|
|
4882
5158
|
|
|
4883
5159
|
// Start streaming inference (non-blocking — driveStream runs in background)
|
|
4884
|
-
|
|
5160
|
+
// The request that grants the turn-alive exception must also define the
|
|
5161
|
+
// downstream turn semantics. An ordinary wake can already be pending
|
|
5162
|
+
// when the tool boundary appends a context-budget restart; selecting
|
|
5163
|
+
// requests[0] in that mixed batch bypassed the turn lock because a
|
|
5164
|
+
// restart existed, then treated the continuation as a fresh turn.
|
|
5165
|
+
const trigger = budgetRestart ?? requests[0];
|
|
4885
5166
|
// Route this turn's auto-published speech to the channel that triggered
|
|
4886
5167
|
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
4887
5168
|
// (messages arrived in >1 channel while the agent was busy/idle):
|
|
@@ -4963,7 +5244,9 @@ export class AgentFramework {
|
|
|
4963
5244
|
}
|
|
4964
5245
|
const suppressedNote =
|
|
4965
5246
|
suppressed > 0
|
|
4966
|
-
?
|
|
5247
|
+
? agent.proseRouting === 'disabled'
|
|
5248
|
+
? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)`
|
|
5249
|
+
: `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
4967
5250
|
: '';
|
|
4968
5251
|
const text =
|
|
4969
5252
|
shown.length > 0
|
|
@@ -5110,6 +5393,97 @@ export class AgentFramework {
|
|
|
5110
5393
|
}
|
|
5111
5394
|
}
|
|
5112
5395
|
|
|
5396
|
+
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
5397
|
+
private async deliverHybridProse(
|
|
5398
|
+
agent: Agent,
|
|
5399
|
+
rawText: string,
|
|
5400
|
+
locus: string | null,
|
|
5401
|
+
allowLocus: boolean,
|
|
5402
|
+
): Promise<void> {
|
|
5403
|
+
const lines = rawText.split('\n');
|
|
5404
|
+
const envelopes: string[] = [];
|
|
5405
|
+
let current: string[] = [];
|
|
5406
|
+
for (const line of lines) {
|
|
5407
|
+
if (line.trimStart().startsWith('>>>') && current.length > 0) {
|
|
5408
|
+
envelopes.push(current.join('\n'));
|
|
5409
|
+
current = [];
|
|
5410
|
+
}
|
|
5411
|
+
current.push(line);
|
|
5412
|
+
}
|
|
5413
|
+
if (current.length > 0) envelopes.push(current.join('\n'));
|
|
5414
|
+
|
|
5415
|
+
for (const envelope of envelopes) {
|
|
5416
|
+
if (!envelope.trim()) continue;
|
|
5417
|
+
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
5418
|
+
const attempted = normalized.startsWith('>>>');
|
|
5419
|
+
const parsed = parseHybridProsePrefix(normalized);
|
|
5420
|
+
if (parsed.continueTurn) this.proseContinuations.add(agent.name);
|
|
5421
|
+
if (parsed.kind === 'private') {
|
|
5422
|
+
this.proseTargetPins.delete(agent.name);
|
|
5423
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5424
|
+
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
5425
|
+
continue;
|
|
5426
|
+
}
|
|
5427
|
+
if (parsed.kind === 'target') {
|
|
5428
|
+
const resolved = this.channelRegistry!.resolveProseTarget(parsed.target!);
|
|
5429
|
+
if ('error' in resolved) {
|
|
5430
|
+
this.proseTargetPins.delete(agent.name);
|
|
5431
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5432
|
+
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
5433
|
+
continue;
|
|
5434
|
+
}
|
|
5435
|
+
let body = parsed.body;
|
|
5436
|
+
const usedClipboard = body.includes('{{unsent}}');
|
|
5437
|
+
if (usedClipboard) body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
5438
|
+
this.proseTargetPins.set(agent.name, resolved.channelId);
|
|
5439
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
5440
|
+
if (!body.trim()) {
|
|
5441
|
+
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
5442
|
+
continue;
|
|
5443
|
+
}
|
|
5444
|
+
try {
|
|
5445
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, body, resolved.channelId);
|
|
5446
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5447
|
+
if (outcome?.delivered) {
|
|
5448
|
+
this.proseBounceStreaks.delete(agent.name);
|
|
5449
|
+
if (usedClipboard) this.proseClipboards.delete(agent.name);
|
|
5450
|
+
} else {
|
|
5451
|
+
this.proseTargetPins.delete(agent.name);
|
|
5452
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5453
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
5454
|
+
}
|
|
5455
|
+
} catch (err) {
|
|
5456
|
+
this.proseTargetPins.delete(agent.name);
|
|
5457
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5458
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
5459
|
+
}
|
|
5460
|
+
continue;
|
|
5461
|
+
}
|
|
5462
|
+
if (attempted) {
|
|
5463
|
+
this.proseTargetPins.delete(agent.name);
|
|
5464
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
5465
|
+
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
5466
|
+
continue;
|
|
5467
|
+
}
|
|
5468
|
+
if (this.proseHybridSuppressed.has(agent.name)) {
|
|
5469
|
+
this.recordProseSuppression(agent.name, 1);
|
|
5470
|
+
continue;
|
|
5471
|
+
}
|
|
5472
|
+
const sticky = this.proseTargetPins.get(agent.name);
|
|
5473
|
+
if (!allowLocus && !sticky) {
|
|
5474
|
+
this.recordProseSuppression(agent.name, 1);
|
|
5475
|
+
continue;
|
|
5476
|
+
}
|
|
5477
|
+
const target = sticky ?? locus;
|
|
5478
|
+
try {
|
|
5479
|
+
const outcome = await this.channelRegistry!.routeSpeech(agent.name, envelope, target);
|
|
5480
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
5481
|
+
} catch (err) {
|
|
5482
|
+
console.error('hybrid locus delivery failed:', err);
|
|
5483
|
+
}
|
|
5484
|
+
}
|
|
5485
|
+
}
|
|
5486
|
+
|
|
5113
5487
|
private async deliverProseEnvelope(agent: Agent, rawText: string): Promise<void> {
|
|
5114
5488
|
const name = agent.name;
|
|
5115
5489
|
const parsed = parseProsePrefix(rawText);
|
|
@@ -5169,7 +5543,7 @@ export class AgentFramework {
|
|
|
5169
5543
|
/** Cap on consecutive bounce-triggered wakes (notices still append after). */
|
|
5170
5544
|
private static readonly PROSE_BOUNCE_WAKE_CAP = 2;
|
|
5171
5545
|
|
|
5172
|
-
private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[]): void {
|
|
5546
|
+
private bounceProse(agent: Agent, text: string, reason: string, candidates?: string[], prefix: '>>' | '>>>' = '>>'): void {
|
|
5173
5547
|
const name = agent.name;
|
|
5174
5548
|
this.proseClipboards.set(name, text);
|
|
5175
5549
|
const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
|
|
@@ -5178,8 +5552,9 @@ export class AgentFramework {
|
|
|
5178
5552
|
const notice =
|
|
5179
5553
|
`[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
|
|
5180
5554
|
'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
|
|
5181
|
-
|
|
5182
|
-
|
|
5555
|
+
`destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
|
|
5556
|
+
`"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
|
|
5557
|
+
(prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
|
|
5183
5558
|
try {
|
|
5184
5559
|
// Through framework.addMessage, NOT the context manager directly: while
|
|
5185
5560
|
// the turn is still streaming this defers the notice to the next tool
|
|
@@ -5241,6 +5616,13 @@ export class AgentFramework {
|
|
|
5241
5616
|
console.error('maybePrimeProseMode: state read/write failed:', err);
|
|
5242
5617
|
return;
|
|
5243
5618
|
}
|
|
5619
|
+
// Disabled mode is intentionally silent: no model-visible routing prose.
|
|
5620
|
+
// The resident publishes only through explicit tools. This contains
|
|
5621
|
+
// continuity-summary locus leaks (Host #96).
|
|
5622
|
+
if (mode === 'disabled') {
|
|
5623
|
+
console.error(`[prose] ${agent.name}: disabled mode recorded (no primer injected)`);
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5244
5626
|
try {
|
|
5245
5627
|
const id = agent.getContextManager().addMessage(
|
|
5246
5628
|
'user',
|
|
@@ -5254,7 +5636,38 @@ export class AgentFramework {
|
|
|
5254
5636
|
}
|
|
5255
5637
|
}
|
|
5256
5638
|
|
|
5257
|
-
private async startAgentStream(
|
|
5639
|
+
private async startAgentStream(
|
|
5640
|
+
agent: Agent,
|
|
5641
|
+
trigger?: InferenceRequest,
|
|
5642
|
+
attempt = 0,
|
|
5643
|
+
providerGateAlreadyHeld = false,
|
|
5644
|
+
): Promise<void> {
|
|
5645
|
+
const ownsProviderGate =
|
|
5646
|
+
!this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
|
|
5647
|
+
if (ownsProviderGate && !providerGateAlreadyHeld) {
|
|
5648
|
+
this.acquirePrimaryProviderGate(agent.name);
|
|
5649
|
+
const gate = this.providerGate(agent.name);
|
|
5650
|
+
if (gate.auxiliaryInFlight > 0) {
|
|
5651
|
+
// Do not await inside processInferenceRequests: one slow auxiliary call for
|
|
5652
|
+
// this resident must not block events or other residents. Admission stays
|
|
5653
|
+
// owned and the continuation receives it after the in-flight call settles.
|
|
5654
|
+
void this.waitForAuxiliaryIdle(agent.name).then(async () => {
|
|
5655
|
+
if (this.providerAdmissionClosed) {
|
|
5656
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
5657
|
+
return;
|
|
5658
|
+
}
|
|
5659
|
+
await this.startAgentStream(agent, trigger, attempt, true);
|
|
5660
|
+
}).catch((error) => {
|
|
5661
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
5662
|
+
console.error(`[provider-cooldown] failed to resume primary for ${agent.name}:`, error);
|
|
5663
|
+
});
|
|
5664
|
+
return;
|
|
5665
|
+
}
|
|
5666
|
+
if (this.providerAdmissionClosed) {
|
|
5667
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
5668
|
+
return;
|
|
5669
|
+
}
|
|
5670
|
+
}
|
|
5258
5671
|
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
5259
5672
|
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
5260
5673
|
// (addMessage guard) instead of appending — nothing may enter the window
|
|
@@ -5275,11 +5688,12 @@ export class AgentFramework {
|
|
|
5275
5688
|
this.activeTurnTokens.set(agent.name, turnToken);
|
|
5276
5689
|
let tokenHandedOff = false;
|
|
5277
5690
|
try {
|
|
5278
|
-
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
5691
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
5279
5692
|
} finally {
|
|
5280
5693
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
5281
5694
|
this.activeTurnTokens.delete(agent.name);
|
|
5282
5695
|
}
|
|
5696
|
+
if (!tokenHandedOff && ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
5283
5697
|
}
|
|
5284
5698
|
}
|
|
5285
5699
|
|
|
@@ -5293,6 +5707,7 @@ export class AgentFramework {
|
|
|
5293
5707
|
trigger: InferenceRequest | undefined,
|
|
5294
5708
|
attempt: number,
|
|
5295
5709
|
turnToken: number,
|
|
5710
|
+
ownsProviderGate: boolean,
|
|
5296
5711
|
): Promise<boolean> {
|
|
5297
5712
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
5298
5713
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
@@ -5381,10 +5796,12 @@ export class AgentFramework {
|
|
|
5381
5796
|
this.turnEngagedChannels.delete(agent.name);
|
|
5382
5797
|
this.turnProseDeliveries.delete(agent.name);
|
|
5383
5798
|
this.turnProseSuppressed.delete(agent.name);
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
//
|
|
5799
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
5800
|
+
if (agent.proseRouting === 'hybrid') this.proseTargetPins.delete(agent.name);
|
|
5801
|
+
if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
|
|
5802
|
+
// Explicit and disabled prose routing have no inferred locus.
|
|
5803
|
+
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
5804
|
+
// prose target at all. Neither mode freezes or announces a locus.
|
|
5388
5805
|
this.proseTargetPins.delete(agent.name);
|
|
5389
5806
|
this.proseContinuations.delete(agent.name);
|
|
5390
5807
|
this.midTurnInputSignals.delete(agent.name);
|
|
@@ -5478,6 +5895,7 @@ export class AgentFramework {
|
|
|
5478
5895
|
trigger,
|
|
5479
5896
|
attempt,
|
|
5480
5897
|
compiledRequest,
|
|
5898
|
+
ownsProviderGate,
|
|
5481
5899
|
);
|
|
5482
5900
|
this.activeStreams.set(agent.name, handle);
|
|
5483
5901
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
@@ -5497,6 +5915,13 @@ export class AgentFramework {
|
|
|
5497
5915
|
});
|
|
5498
5916
|
agent.reset();
|
|
5499
5917
|
|
|
5918
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5919
|
+
// Capacity scheduling is not poisoned history. The cooldown timer owns
|
|
5920
|
+
// one later fresh compile; no immediate retry or exhaustion marker.
|
|
5921
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5922
|
+
return false;
|
|
5923
|
+
}
|
|
5924
|
+
|
|
5500
5925
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
5501
5926
|
if (action.retry) {
|
|
5502
5927
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
@@ -5550,7 +5975,8 @@ export class AgentFramework {
|
|
|
5550
5975
|
requestSnapshot: InferenceToolSnapshot,
|
|
5551
5976
|
trigger?: InferenceRequest,
|
|
5552
5977
|
attempt = 0,
|
|
5553
|
-
compiledRequest?: NormalizedRequest
|
|
5978
|
+
compiledRequest?: NormalizedRequest,
|
|
5979
|
+
ownsProviderGate = false,
|
|
5554
5980
|
): Promise<void> {
|
|
5555
5981
|
const startTime = Date.now();
|
|
5556
5982
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
@@ -5615,9 +6041,11 @@ export class AgentFramework {
|
|
|
5615
6041
|
// sent here", which is true regardless of where the reply goes.
|
|
5616
6042
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
5617
6043
|
const typingChannel =
|
|
5618
|
-
agent.proseRouting === '
|
|
5619
|
-
?
|
|
5620
|
-
:
|
|
6044
|
+
agent.proseRouting === 'disabled'
|
|
6045
|
+
? null
|
|
6046
|
+
: agent.proseRouting === 'explicit'
|
|
6047
|
+
? trigger?.channelId ?? null
|
|
6048
|
+
: resolveTurnLocus();
|
|
5621
6049
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
5622
6050
|
|
|
5623
6051
|
// MCPL Spec 14.3 outgoing streaming: route text deltas to their
|
|
@@ -5647,9 +6075,9 @@ export class AgentFramework {
|
|
|
5647
6075
|
turnIndex: 0,
|
|
5648
6076
|
phase: 'started',
|
|
5649
6077
|
});
|
|
5650
|
-
const proseStream = this.channelRegistry
|
|
6078
|
+
const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
|
|
5651
6079
|
? new ProseStreamRouter({
|
|
5652
|
-
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
6080
|
+
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
5653
6081
|
initialTarget: typingChannel,
|
|
5654
6082
|
resolve: (spec) => {
|
|
5655
6083
|
const r = this.channelRegistry!.resolveProseTarget(spec);
|
|
@@ -5776,9 +6204,18 @@ export class AgentFramework {
|
|
|
5776
6204
|
const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
|
|
5777
6205
|
? rawText
|
|
5778
6206
|
: undefined;
|
|
6207
|
+
// Persist THIS round's text only. `roundPreamble` (membrane
|
|
6208
|
+
// ≥0.5.79) is the per-round delta; the legacy `preamble` is
|
|
6209
|
+
// CUMULATIVE in XML mode — persisting it per round stored each
|
|
6210
|
+
// round's text N times and re-persisted injected
|
|
6211
|
+
// <function_results> as the agent's own words (the Evander
|
|
6212
|
+
// 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
|
|
6213
|
+
const roundText =
|
|
6214
|
+
(event.context as { roundPreamble?: string }).roundPreamble ??
|
|
6215
|
+
event.context.preamble;
|
|
5779
6216
|
assistantBlocks = [];
|
|
5780
|
-
if (
|
|
5781
|
-
assistantBlocks.push({ type: 'text', text:
|
|
6217
|
+
if (roundText) {
|
|
6218
|
+
assistantBlocks.push({ type: 'text', text: roundText });
|
|
5782
6219
|
}
|
|
5783
6220
|
for (const c of event.calls) {
|
|
5784
6221
|
assistantBlocks.push({
|
|
@@ -5820,14 +6257,33 @@ export class AgentFramework {
|
|
|
5820
6257
|
const hasSameRoundPrivateThink =
|
|
5821
6258
|
roundToolNames.includes('think') &&
|
|
5822
6259
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
5823
|
-
if (roundToolNames.some((n) =>
|
|
6260
|
+
if (roundToolNames.some((n) =>
|
|
6261
|
+
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
6262
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
6263
|
+
)) {
|
|
5824
6264
|
turnSilenced = true;
|
|
5825
6265
|
}
|
|
5826
6266
|
if (roundContent && roundContent.length > 0) {
|
|
5827
6267
|
liveProseRouting = true;
|
|
5828
6268
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
5829
6269
|
if (roundSegments.length > 0) {
|
|
5830
|
-
if (agent.proseRouting === '
|
|
6270
|
+
if (agent.proseRouting === 'disabled') {
|
|
6271
|
+
console.error(
|
|
6272
|
+
`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`,
|
|
6273
|
+
);
|
|
6274
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
6275
|
+
} else if (agent.proseRouting === 'hybrid') {
|
|
6276
|
+
if (turnSilenced) {
|
|
6277
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
6278
|
+
} else if (!hasSameRoundPrivateThink) {
|
|
6279
|
+
const locus = resolveTurnLocus();
|
|
6280
|
+
for (const seg of roundSegments) {
|
|
6281
|
+
turnSpeechChain = turnSpeechChain
|
|
6282
|
+
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
6283
|
+
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
6284
|
+
}
|
|
6285
|
+
}
|
|
6286
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
5831
6287
|
// Explicit mode: every segment through the prose gateway.
|
|
5832
6288
|
// Silencing does not apply — unprefixed prose bounces and
|
|
5833
6289
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -5932,6 +6388,10 @@ export class AgentFramework {
|
|
|
5932
6388
|
agent.addAssistantResponse(terminalContent);
|
|
5933
6389
|
}
|
|
5934
6390
|
|
|
6391
|
+
// Bind the cooldown receipt to this fresh, successful request —
|
|
6392
|
+
// never to the frozen request that received the 429.
|
|
6393
|
+
this.recordProviderAccelerationRecovery(agent, compiledRequest, response.stopReason);
|
|
6394
|
+
|
|
5935
6395
|
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
5936
6396
|
// content leaves the host here. Turn boundaries are announced
|
|
5937
6397
|
// by the metadata-only inference/lifecycle notifications
|
|
@@ -6157,7 +6617,18 @@ export class AgentFramework {
|
|
|
6157
6617
|
.join('\n')
|
|
6158
6618
|
.trim();
|
|
6159
6619
|
if (speechText) {
|
|
6160
|
-
if (agent.proseRouting === '
|
|
6620
|
+
if (agent.proseRouting === 'disabled') {
|
|
6621
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
6622
|
+
this.recordProseSuppression(agent.name, 1);
|
|
6623
|
+
} else if (agent.proseRouting === 'hybrid') {
|
|
6624
|
+
const locus = resolveTurnLocus();
|
|
6625
|
+
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
6626
|
+
try {
|
|
6627
|
+
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
6628
|
+
} catch (err) {
|
|
6629
|
+
console.error('text-only hybrid prose delivery failed:', err);
|
|
6630
|
+
}
|
|
6631
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
6161
6632
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
6162
6633
|
try {
|
|
6163
6634
|
await this.deliverProse(agent, speechText);
|
|
@@ -6204,7 +6675,10 @@ export class AgentFramework {
|
|
|
6204
6675
|
.filter((n): n is string => typeof n === 'string');
|
|
6205
6676
|
const silenced = liveProseRouting
|
|
6206
6677
|
? turnSilenced
|
|
6207
|
-
: turnSilenced || toolNames.some((n) =>
|
|
6678
|
+
: turnSilenced || toolNames.some((n) =>
|
|
6679
|
+
SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
6680
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n)))
|
|
6681
|
+
);
|
|
6208
6682
|
|
|
6209
6683
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
6210
6684
|
|
|
@@ -6213,7 +6687,27 @@ export class AgentFramework {
|
|
|
6213
6687
|
// the chain may still be flushing earlier rounds' posts.
|
|
6214
6688
|
await turnSpeechChain;
|
|
6215
6689
|
|
|
6216
|
-
if (agent.proseRouting === '
|
|
6690
|
+
if (agent.proseRouting === 'disabled') {
|
|
6691
|
+
if (segments.length > 0) {
|
|
6692
|
+
console.error(
|
|
6693
|
+
`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`,
|
|
6694
|
+
);
|
|
6695
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
6696
|
+
}
|
|
6697
|
+
} else if (agent.proseRouting === 'hybrid') {
|
|
6698
|
+
if (silenced && segments.length > 0) {
|
|
6699
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
6700
|
+
} else if (segments.length > 0) {
|
|
6701
|
+
const locus = resolveTurnLocus();
|
|
6702
|
+
for (const seg of segments) {
|
|
6703
|
+
try {
|
|
6704
|
+
await this.deliverHybridProse(agent, seg, locus, true);
|
|
6705
|
+
} catch (err) {
|
|
6706
|
+
console.error('trailing hybrid prose delivery failed:', err);
|
|
6707
|
+
}
|
|
6708
|
+
}
|
|
6709
|
+
}
|
|
6710
|
+
} else if (agent.proseRouting === 'explicit') {
|
|
6217
6711
|
if (segments.length > 0) {
|
|
6218
6712
|
console.error(
|
|
6219
6713
|
`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`,
|
|
@@ -6314,6 +6808,12 @@ export class AgentFramework {
|
|
|
6314
6808
|
this.abortAgentScript(agent.name, 'stream error');
|
|
6315
6809
|
agent.reset();
|
|
6316
6810
|
|
|
6811
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6812
|
+
lifecyclePhase = 'failed';
|
|
6813
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6814
|
+
break;
|
|
6815
|
+
}
|
|
6816
|
+
|
|
6317
6817
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
6318
6818
|
if (action.retry) {
|
|
6319
6819
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
@@ -6453,10 +6953,19 @@ export class AgentFramework {
|
|
|
6453
6953
|
}
|
|
6454
6954
|
}
|
|
6455
6955
|
} catch (error) {
|
|
6456
|
-
// Stream itself threw
|
|
6457
|
-
//
|
|
6956
|
+
// Stream itself threw. Organization acceleration is deferred from a
|
|
6957
|
+
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
6458
6958
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
6459
6959
|
const durationMs = Date.now() - startTime;
|
|
6960
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6961
|
+
this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
|
|
6962
|
+
this.logInference({ timestamp: startTime, agentName: agent.name, requestId, success: false,
|
|
6963
|
+
error: `Provider acceleration cooldown: ${err.message}`,
|
|
6964
|
+
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
6965
|
+
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6966
|
+
lifecyclePhase = 'failed'; agent.reset(); this.eventGate?.onInferenceEnded(agent.name);
|
|
6967
|
+
return;
|
|
6968
|
+
}
|
|
6460
6969
|
this.emitTrace({
|
|
6461
6970
|
type: 'inference:failed',
|
|
6462
6971
|
agentName: agent.name,
|
|
@@ -6511,6 +7020,7 @@ export class AgentFramework {
|
|
|
6511
7020
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
6512
7021
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
6513
7022
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
7023
|
+
if (ownsProviderGate) this.releasePrimaryProviderGate(agent.name);
|
|
6514
7024
|
|
|
6515
7025
|
// Stop the typing indicator on every exit path (complete, error,
|
|
6516
7026
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
@@ -6576,6 +7086,107 @@ export class AgentFramework {
|
|
|
6576
7086
|
});
|
|
6577
7087
|
}
|
|
6578
7088
|
|
|
7089
|
+
/**
|
|
7090
|
+
* Admin puppet: execute ONE tool call AS an agent and persist the
|
|
7091
|
+
* tool_use + tool_result pair in that agent's window exactly as a
|
|
7092
|
+
* model-initiated call is stored.
|
|
7093
|
+
*
|
|
7094
|
+
* Born from the princess exemplar surgery (2026-08-23). Her folded
|
|
7095
|
+
* narrative said "I have no tools"; every past tool_use/tool_result pair
|
|
7096
|
+
* had folded out of her context, so the act of calling had no in-context
|
|
7097
|
+
* exemplar and she could not emit her first call despite 30 eidoverse
|
|
7098
|
+
* tools riding every request. Placing one first-person pair restored the
|
|
7099
|
+
* capacity. That repair required stop → store surgery → restart; this
|
|
7100
|
+
* method is the live path — and the general lever for demonstrating an
|
|
7101
|
+
* affordance to a model that cannot find it (older models especially).
|
|
7102
|
+
*
|
|
7103
|
+
* Semantics:
|
|
7104
|
+
* - Refused unless the agent is idle: puppeting mid-turn would corrupt
|
|
7105
|
+
* the turn state machine and the live stream's wire ordering.
|
|
7106
|
+
* - Refused for tools outside the agent's own surface (canUseTool): the
|
|
7107
|
+
* stored pair must be an act the agent could genuinely have taken.
|
|
7108
|
+
* - The call executes FOR REAL through the shared dispatch (MCPL,
|
|
7109
|
+
* channel tools, utils, modules) with the agent's provenance — a
|
|
7110
|
+
* puppeted walk_to actually moves the avatar.
|
|
7111
|
+
* - Storage byte-follows the ordinary path: a bare tool_use assistant
|
|
7112
|
+
* message (no fabricated thinking or text — a stored assistant turn
|
|
7113
|
+
* with unverifiable thinking is the labclaude 400 class) plus a
|
|
7114
|
+
* tool_result under the same bounded spill policy (issue #89).
|
|
7115
|
+
* - Does NOT request inference; the agent sees the pair on its next
|
|
7116
|
+
* wake. Speak to the agent afterward if a wake is wanted.
|
|
7117
|
+
* - Provenance is loud in traces (puppet:tool-call) and the host log,
|
|
7118
|
+
* deliberately NOT in the stored messages — metadata there would break
|
|
7119
|
+
* byte-parity with real turns. Whether to disclose to the resident is
|
|
7120
|
+
* the operator's call; the princess precedent was disclosed first.
|
|
7121
|
+
*/
|
|
7122
|
+
async puppetToolCall(
|
|
7123
|
+
agentName: string,
|
|
7124
|
+
toolName: string,
|
|
7125
|
+
input: Record<string, unknown>,
|
|
7126
|
+
): Promise<{ toolUseId: string; result: ToolResult }> {
|
|
7127
|
+
const agent = this.agents.get(agentName);
|
|
7128
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
7129
|
+
if (agent.state.status !== 'idle') {
|
|
7130
|
+
throw new Error(
|
|
7131
|
+
`puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
|
|
7132
|
+
`injecting a turn under an active stream corrupts wire ordering)`,
|
|
7133
|
+
);
|
|
7134
|
+
}
|
|
7135
|
+
const onSurface = this.getToolsForAgent(agentName)
|
|
7136
|
+
.some((t) => t.name === toolName && agent.canUseTool(t.name));
|
|
7137
|
+
if (!onSurface) {
|
|
7138
|
+
throw new Error(
|
|
7139
|
+
`puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
|
|
7140
|
+
`the stored pair must be an act the agent could genuinely have taken`,
|
|
7141
|
+
);
|
|
7142
|
+
}
|
|
7143
|
+
|
|
7144
|
+
// Anthropic-shaped id so the stored pair is indistinguishable from a
|
|
7145
|
+
// provider-issued call.
|
|
7146
|
+
const alphabet = 'ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz0123456789';
|
|
7147
|
+
let suffix = '';
|
|
7148
|
+
for (let i = 0; i < 22; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
7149
|
+
const toolUseId = `toolu_01${suffix}`;
|
|
7150
|
+
|
|
7151
|
+
const started = Date.now();
|
|
7152
|
+
const result = await this.executeToolCall({
|
|
7153
|
+
id: toolUseId,
|
|
7154
|
+
name: toolName,
|
|
7155
|
+
input,
|
|
7156
|
+
callerAgentName: agentName,
|
|
7157
|
+
});
|
|
7158
|
+
const durationMs = Date.now() - started;
|
|
7159
|
+
|
|
7160
|
+
// Store the pair through the same shapes the ordinary path uses. Build
|
|
7161
|
+
// the result blocks BEFORE storing the tool_use: the spill path awaits,
|
|
7162
|
+
// and a message arriving during that await must land before the pair,
|
|
7163
|
+
// never between tool_use and its tool_result. The two addMessage calls
|
|
7164
|
+
// below are synchronous and adjacent — nothing can interleave.
|
|
7165
|
+
const { blocks } = await this.buildStoredToolResultContent(
|
|
7166
|
+
[{ id: toolUseId, name: toolName, input, result, durationMs }],
|
|
7167
|
+
this.resolveToolResultInlineCap(agent).cap,
|
|
7168
|
+
);
|
|
7169
|
+
const cm = agent.getContextManager();
|
|
7170
|
+
cm.addMessage(agentName, [
|
|
7171
|
+
{ type: 'tool_use', id: toolUseId, name: toolName, input } as ContentBlock,
|
|
7172
|
+
]);
|
|
7173
|
+
cm.addMessage('user', blocks);
|
|
7174
|
+
|
|
7175
|
+
this.emitTrace({
|
|
7176
|
+
type: 'puppet:tool-call',
|
|
7177
|
+
agentName,
|
|
7178
|
+
toolName,
|
|
7179
|
+
toolUseId,
|
|
7180
|
+
isError: !!result.isError,
|
|
7181
|
+
durationMs,
|
|
7182
|
+
});
|
|
7183
|
+
console.log(
|
|
7184
|
+
`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
|
|
7185
|
+
`in ${durationMs}ms (${toolUseId})`,
|
|
7186
|
+
);
|
|
7187
|
+
return { toolUseId, result };
|
|
7188
|
+
}
|
|
7189
|
+
|
|
6579
7190
|
private async executeToolCallFrom(call: ToolCall, origin: ChannelToolOrigin): Promise<ToolResult> {
|
|
6580
7191
|
// Client-side programmatic tool calling for promise-based callers
|
|
6581
7192
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
@@ -7075,6 +7686,9 @@ export class AgentFramework {
|
|
|
7075
7686
|
const blocks: ContentBlock[] = toolResults.map(tc => ({
|
|
7076
7687
|
type: 'tool_result' as const,
|
|
7077
7688
|
toolUseId: tc.id,
|
|
7689
|
+
// Persisted so XML replay can reconstruct the legacy <tool_name>
|
|
7690
|
+
// element byte-identically to the live injection.
|
|
7691
|
+
toolName: tc.name,
|
|
7078
7692
|
content: spilled.get(tc.id)!.text,
|
|
7079
7693
|
isError: tc.result.isError,
|
|
7080
7694
|
}));
|
|
@@ -7334,7 +7948,7 @@ export class AgentFramework {
|
|
|
7334
7948
|
/**
|
|
7335
7949
|
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
7336
7950
|
* value: resident's durable agent_settings value → residence
|
|
7337
|
-
* FrameworkConfig.toolResultInlineMaxChars → house default (
|
|
7951
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (24000).
|
|
7338
7952
|
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
7339
7953
|
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
7340
7954
|
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
@@ -7836,6 +8450,19 @@ export class AgentFramework {
|
|
|
7836
8450
|
name,
|
|
7837
8451
|
status: agent.state.status,
|
|
7838
8452
|
consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
|
|
8453
|
+
providerAdmission: (() => {
|
|
8454
|
+
const gate = this.providerGates?.get(name);
|
|
8455
|
+
const cooldown = this.providerAccelerationCooldowns?.get(name);
|
|
8456
|
+
return {
|
|
8457
|
+
primaryActive: (gate?.primaryDepth ?? 0) > 0,
|
|
8458
|
+
primaryPending: gate?.primaryPending ?? false,
|
|
8459
|
+
auxiliaryInFlight: gate?.auxiliaryInFlight ?? 0,
|
|
8460
|
+
auxiliaryDeferred: gate?.deferredAuxiliary ?? 0,
|
|
8461
|
+
cooldownUntil: cooldown?.until ?? null,
|
|
8462
|
+
heldRequests: cooldown?.heldRequests.length ?? 0,
|
|
8463
|
+
lastRecovery: this.providerAccelerationLastRecovery?.get(name) ?? null,
|
|
8464
|
+
};
|
|
8465
|
+
})(),
|
|
7839
8466
|
lastInference: this.lastInferenceAt.get(name) ?? null,
|
|
7840
8467
|
refusalStats: this.refusalStats.get(name) ?? null,
|
|
7841
8468
|
})),
|
|
@@ -9886,7 +10513,7 @@ export class AgentFramework {
|
|
|
9886
10513
|
if (opened) {
|
|
9887
10514
|
this.activeTriggerChannels.set(agentName, opened);
|
|
9888
10515
|
const openerAgent = this.agents.get(agentName);
|
|
9889
|
-
if (openerAgent && openerAgent.proseRouting
|
|
10516
|
+
if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
|
|
9890
10517
|
this.turnLocusPins.set(agentName, opened);
|
|
9891
10518
|
this.lastAnnouncedLocus.set(agentName, opened);
|
|
9892
10519
|
result = {
|
|
@@ -9960,6 +10587,7 @@ export class AgentFramework {
|
|
|
9960
10587
|
// range/shape checks.
|
|
9961
10588
|
const input = (call.input ?? {}) as {
|
|
9962
10589
|
name?: unknown; match?: unknown; resets?: unknown; position?: unknown;
|
|
10590
|
+
insertBefore?: unknown; insertAfter?: unknown; passthrough?: unknown;
|
|
9963
10591
|
behavior?: unknown; debounceMs?: unknown; rateLimit?: unknown; passiveSample?: unknown;
|
|
9964
10592
|
};
|
|
9965
10593
|
const behaviorSources = [
|
|
@@ -9990,17 +10618,72 @@ export class AgentFramework {
|
|
|
9990
10618
|
: input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
|
|
9991
10619
|
: input.behavior;
|
|
9992
10620
|
|
|
9993
|
-
|
|
10621
|
+
// Placement: at most one of position / insertBefore / insertAfter.
|
|
10622
|
+
// With none given, a replaced rule stays where it was and a new rule
|
|
10623
|
+
// appends (addPolicy's contract).
|
|
10624
|
+
const placementSources = [
|
|
10625
|
+
input.position !== undefined ? 'position' : null,
|
|
10626
|
+
input.insertBefore !== undefined ? 'insertBefore' : null,
|
|
10627
|
+
input.insertAfter !== undefined ? 'insertAfter' : null,
|
|
10628
|
+
].filter((s): s is string => s !== null);
|
|
10629
|
+
if (placementSources.length > 1) {
|
|
10630
|
+
finish({
|
|
10631
|
+
success: false,
|
|
10632
|
+
error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
|
|
10633
|
+
isError: true,
|
|
10634
|
+
});
|
|
10635
|
+
return;
|
|
10636
|
+
}
|
|
10637
|
+
let position: 'append' | 'prepend' | { before: string } | { after: string } | undefined;
|
|
10638
|
+
if (typeof input.insertBefore === 'string' && input.insertBefore) {
|
|
10639
|
+
position = { before: input.insertBefore };
|
|
10640
|
+
} else if (typeof input.insertAfter === 'string' && input.insertAfter) {
|
|
10641
|
+
position = { after: input.insertAfter };
|
|
10642
|
+
} else if (input.position === 'prepend' || input.position === 'append') {
|
|
10643
|
+
position = input.position;
|
|
10644
|
+
}
|
|
10645
|
+
|
|
9994
10646
|
const rawPolicy = {
|
|
9995
10647
|
name: input.name,
|
|
9996
10648
|
match: input.match ?? {},
|
|
9997
10649
|
behavior,
|
|
10650
|
+
...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
|
|
9998
10651
|
...(input.resets !== undefined ? { resets: input.resets } : {}),
|
|
9999
10652
|
};
|
|
10000
|
-
|
|
10653
|
+
|
|
10654
|
+
// Before/after probe table + shadow lint: the moment a rule changes the
|
|
10655
|
+
// evaluation order is the moment its real semantics must be legible
|
|
10656
|
+
// (af#105 — a prepended catch-all sampler silently disabled the
|
|
10657
|
+
// addressed-message wakes for 12h before anyone could see why).
|
|
10658
|
+
const before = new Map(
|
|
10659
|
+
this.eventGate!.probeTable().map((r) => [r.probe, r]),
|
|
10660
|
+
);
|
|
10661
|
+
const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
|
|
10662
|
+
const after = this.eventGate!.probeTable();
|
|
10663
|
+
const probes = after.map((r) => {
|
|
10664
|
+
const prev = before.get(r.probe);
|
|
10665
|
+
const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
|
|
10666
|
+
return {
|
|
10667
|
+
probe: r.probe,
|
|
10668
|
+
rule: r.policy ?? '(default)',
|
|
10669
|
+
behavior: r.behavior,
|
|
10670
|
+
wouldWake: r.wouldWake,
|
|
10671
|
+
...(changed ? { changed: true, was: `${prev!.policy ?? '(default)'} (wouldWake=${prev!.wouldWake})` } : {}),
|
|
10672
|
+
};
|
|
10673
|
+
});
|
|
10674
|
+
const shadowWarnings = this.eventGate!.lintShadows()
|
|
10675
|
+
.map((w) => formatShadowWarning(w));
|
|
10001
10676
|
finish({
|
|
10002
10677
|
success: true,
|
|
10003
|
-
data: {
|
|
10678
|
+
data: {
|
|
10679
|
+
added: policy.name,
|
|
10680
|
+
policies: this.getGatePolicyNames(),
|
|
10681
|
+
...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
|
|
10682
|
+
probes,
|
|
10683
|
+
...(probes.some((p) => 'changed' in p)
|
|
10684
|
+
? {}
|
|
10685
|
+
: { note: 'No canonical probe changed its winning rule — the new rule affects none of the common chat/heartbeat shapes, or only channel/path-specific events the probes do not model.' }),
|
|
10686
|
+
},
|
|
10004
10687
|
});
|
|
10005
10688
|
} catch (err) {
|
|
10006
10689
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -10048,7 +10731,9 @@ export class AgentFramework {
|
|
|
10048
10731
|
if (announce && this.channelRegistry) {
|
|
10049
10732
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
10050
10733
|
const agent = this.agents.get(agentName);
|
|
10051
|
-
if (agent?.proseRouting === '
|
|
10734
|
+
if (agent?.proseRouting === 'disabled') {
|
|
10735
|
+
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
10736
|
+
} else if (agent?.proseRouting === 'explicit') {
|
|
10052
10737
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
10053
10738
|
// model has set one; otherwise stay quiet — never guess a channel.
|
|
10054
10739
|
const target = this.proseTargetPins.get(agentName);
|