@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/dist/src/framework.js
CHANGED
|
@@ -13,7 +13,7 @@ import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './m
|
|
|
13
13
|
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
|
|
14
14
|
import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
15
15
|
import { PushHandler } from './mcpl/push-handler.js';
|
|
16
|
-
import { parseProsePrefix } from './mcpl/prose-grammar.js';
|
|
16
|
+
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
|
|
17
17
|
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
|
|
18
18
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
19
19
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
@@ -64,6 +64,8 @@ function sniffImageMediaType(data) {
|
|
|
64
64
|
const SILENCING_TOOLS = new Set([
|
|
65
65
|
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
66
66
|
]);
|
|
67
|
+
/** World-surface publication names that outrank hybrid prose envelopes. */
|
|
68
|
+
const HYBRID_PUBLICATION_TOOLS = new Set(['say', 'whisper']);
|
|
67
69
|
/**
|
|
68
70
|
* True when an injected message is real conversational input — something the
|
|
69
71
|
* agent might actually be replying to — rather than ambient machinery.
|
|
@@ -159,6 +161,13 @@ function proseModePrimer(mode) {
|
|
|
159
161
|
return '[prose-routing] Mode change: your plain text auto-routes to the ' +
|
|
160
162
|
'conversational locus again. `>>` destination prefixes are no longer needed.';
|
|
161
163
|
}
|
|
164
|
+
if (mode === 'hybrid') {
|
|
165
|
+
return '[prose-routing] Hybrid routing enabled: unprefixed text still lands in the ' +
|
|
166
|
+
'current conversational locus. A leading `>>>destination` envelope instead routes ' +
|
|
167
|
+
'that prose to one uniquely resolved authorized Discord or Eidoverse channel. The ' +
|
|
168
|
+
'envelope remains in your memory but recipients see only its body; delivery or failure ' +
|
|
169
|
+
'is reported back to you.';
|
|
170
|
+
}
|
|
162
171
|
// Deliberately terse: a short event-style notice is classifier-safe from
|
|
163
172
|
// the user role (ablation D, 2026-07-24), while the full grammar as a user
|
|
164
173
|
// message drew a deterministic reasoning_extraction refusal (ablation A/B).
|
|
@@ -249,7 +258,7 @@ function validateUtilityArgs(input, schema) {
|
|
|
249
258
|
}
|
|
250
259
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
251
260
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
252
|
-
import { EventGate } from './gate/event-gate.js';
|
|
261
|
+
import { EventGate, formatShadowWarning } from './gate/event-gate.js';
|
|
253
262
|
import { UsageTracker } from './usage/usage-tracker.js';
|
|
254
263
|
import { formatZonedDateTime, resolveTimeZone } from './timezone.js';
|
|
255
264
|
import { DEFAULT_DISCORD_AWARENESS_EMOJI, DiscordAwarenessOutbox, defaultDiscordAwarenessOutboxPath, extractDiscordAwarenessRefs, } from './recovery/discord-awareness-outbox.js';
|
|
@@ -322,6 +331,15 @@ const DEFAULT_SYNC_INTERVAL_MS = 1000;
|
|
|
322
331
|
const DEFAULT_MAINTENANCE_INTERVAL_MS = 5000;
|
|
323
332
|
/** Bound one pass so a large backlog yields to inference and other agents. */
|
|
324
333
|
const MAINTENANCE_TICKS_PER_PASS = 8;
|
|
334
|
+
const PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS = 65_000;
|
|
335
|
+
const PROVIDER_ACCELERATION_MAX_COOLDOWN_MS = 10 * 60_000;
|
|
336
|
+
const PROVIDER_ACCELERATION_JITTER_MS = 5_000;
|
|
337
|
+
function isOrganizationAccelerationRateLimit(error) {
|
|
338
|
+
if (!(error instanceof MembraneError) || error.type !== 'rate_limit')
|
|
339
|
+
return false;
|
|
340
|
+
return (/organization(?:'s)?[^\n]{0,120}maximum(?:\s+usage)?\s+increase\s+rate/i.test(error.message) ||
|
|
341
|
+
/organization(?:'s)?[^\n]{0,120}acceleration(?:\s+limit)?/i.test(error.message));
|
|
342
|
+
}
|
|
325
343
|
/**
|
|
326
344
|
* Extract fields the EventGate cares about from a ProcessEvent. The set of
|
|
327
345
|
* event variants that carry `content`/`mount`/`paths`/`metadata` is open-ended
|
|
@@ -390,6 +408,13 @@ export class AgentFramework {
|
|
|
390
408
|
maintenanceRunId = 0;
|
|
391
409
|
currentMaintenanceRun = null;
|
|
392
410
|
maintenanceHistory = [];
|
|
411
|
+
providerGates = new Map();
|
|
412
|
+
providerAccelerationCooldowns = new Map();
|
|
413
|
+
providerAccelerationRecoveries = new Map();
|
|
414
|
+
providerAccelerationLastRecovery = new Map();
|
|
415
|
+
providerAccelerationDefaultCooldownMs = PROVIDER_ACCELERATION_DEFAULT_COOLDOWN_MS;
|
|
416
|
+
providerAccelerationJitterMs = PROVIDER_ACCELERATION_JITTER_MS;
|
|
417
|
+
providerAdmissionClosed = false;
|
|
393
418
|
/** Last time we reported stale (busy-requeued) inference requests, per agent. */
|
|
394
419
|
staleWarnAt = new Map();
|
|
395
420
|
/** Per-agent last inference activity (epoch ms), for /healthz + doctor tooling. */
|
|
@@ -459,6 +484,8 @@ export class AgentFramework {
|
|
|
459
484
|
/** Sticky per-TURN delivery target set by the turn's first `>>` prefix.
|
|
460
485
|
* Cleared at every non-restart turn start; survives context restarts. */
|
|
461
486
|
proseTargetPins = new Map();
|
|
487
|
+
/** Hybrid router is fail-closed after a malformed/unresolved envelope until a new valid target. */
|
|
488
|
+
proseHybridSuppressed = new Set();
|
|
462
489
|
/** Agents whose current turn requested `!` continuation — re-woken when the
|
|
463
490
|
* turn completes instead of pausing until the next external event. */
|
|
464
491
|
proseContinuations = new Set();
|
|
@@ -886,6 +913,7 @@ export class AgentFramework {
|
|
|
886
913
|
return;
|
|
887
914
|
}
|
|
888
915
|
this.running = true;
|
|
916
|
+
this.providerAdmissionClosed = false;
|
|
889
917
|
this.loopPromise = this.runLoop();
|
|
890
918
|
// Start periodic sync timer (if enabled)
|
|
891
919
|
if (this.syncIntervalMs > 0) {
|
|
@@ -913,6 +941,7 @@ export class AgentFramework {
|
|
|
913
941
|
*/
|
|
914
942
|
async stop() {
|
|
915
943
|
this.running = false;
|
|
944
|
+
this.providerAdmissionClosed = true;
|
|
916
945
|
this.queue.close();
|
|
917
946
|
// Kill running code_execution scripts before cancelling streams: a
|
|
918
947
|
// zombie script must not keep firing side-effectful tool calls into a
|
|
@@ -932,6 +961,8 @@ export class AgentFramework {
|
|
|
932
961
|
record.runner.dispose();
|
|
933
962
|
}
|
|
934
963
|
this.backgroundScripts.clear();
|
|
964
|
+
// A stopped host must never hang behind its own cooldown.
|
|
965
|
+
this.cancelProviderAdmission();
|
|
935
966
|
// Cancel all active streams
|
|
936
967
|
for (const agent of this.agents.values()) {
|
|
937
968
|
if (agent.state.status === 'streaming' ||
|
|
@@ -988,6 +1019,168 @@ export class AgentFramework {
|
|
|
988
1019
|
* schemas are absent. Passes never overlap; each agent gets a bounded drain
|
|
989
1020
|
* so maintenance cannot monopolize the framework event loop.
|
|
990
1021
|
*/
|
|
1022
|
+
providerGate(agentName) {
|
|
1023
|
+
let gate = this.providerGates.get(agentName);
|
|
1024
|
+
if (!gate) {
|
|
1025
|
+
gate = { primaryDepth: 0, primaryPending: false, auxiliaryInFlight: 0,
|
|
1026
|
+
auxiliaryWaiters: [], idleWaiters: [], deferredAuxiliary: 0 };
|
|
1027
|
+
this.providerGates.set(agentName, gate);
|
|
1028
|
+
}
|
|
1029
|
+
return gate;
|
|
1030
|
+
}
|
|
1031
|
+
providerGateBlocked(agentName) {
|
|
1032
|
+
const gate = this.providerGates.get(agentName);
|
|
1033
|
+
return (gate?.primaryDepth ?? 0) > 0 || (gate?.primaryPending ?? false) ||
|
|
1034
|
+
this.providerAccelerationCooldowns.has(agentName);
|
|
1035
|
+
}
|
|
1036
|
+
acquirePrimaryProviderGate(agentName) {
|
|
1037
|
+
const gate = this.providerGate(agentName);
|
|
1038
|
+
gate.primaryPending = false;
|
|
1039
|
+
gate.primaryDepth++;
|
|
1040
|
+
}
|
|
1041
|
+
releasePrimaryProviderGate(agentName) {
|
|
1042
|
+
const gate = this.providerGate(agentName);
|
|
1043
|
+
gate.primaryDepth = Math.max(0, gate.primaryDepth - 1);
|
|
1044
|
+
this.flushAuxiliaryAdmission(agentName);
|
|
1045
|
+
}
|
|
1046
|
+
flushAuxiliaryAdmission(agentName) {
|
|
1047
|
+
const gate = this.providerGate(agentName);
|
|
1048
|
+
if (gate.primaryDepth > 0 || gate.primaryPending || this.providerAccelerationCooldowns.has(agentName))
|
|
1049
|
+
return;
|
|
1050
|
+
for (const resolve of gate.auxiliaryWaiters.splice(0))
|
|
1051
|
+
resolve();
|
|
1052
|
+
}
|
|
1053
|
+
async waitForAuxiliaryIdle(agentName) {
|
|
1054
|
+
const gate = this.providerGate(agentName);
|
|
1055
|
+
if (gate.auxiliaryInFlight === 0)
|
|
1056
|
+
return;
|
|
1057
|
+
await new Promise((resolve) => gate.idleWaiters.push(resolve));
|
|
1058
|
+
}
|
|
1059
|
+
async withAuxiliaryAdmission(agentName, run) {
|
|
1060
|
+
const gate = this.providerGate(agentName);
|
|
1061
|
+
if (this.providerGateBlocked(agentName)) {
|
|
1062
|
+
gate.deferredAuxiliary++;
|
|
1063
|
+
try {
|
|
1064
|
+
await new Promise((resolve) => gate.auxiliaryWaiters.push(resolve));
|
|
1065
|
+
}
|
|
1066
|
+
finally {
|
|
1067
|
+
gate.deferredAuxiliary = Math.max(0, gate.deferredAuxiliary - 1);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
if (this.providerAdmissionClosed) {
|
|
1071
|
+
throw new Error(`Provider admission closed while stopping (${agentName})`);
|
|
1072
|
+
}
|
|
1073
|
+
gate.auxiliaryInFlight++;
|
|
1074
|
+
try {
|
|
1075
|
+
return await run();
|
|
1076
|
+
}
|
|
1077
|
+
finally {
|
|
1078
|
+
gate.auxiliaryInFlight = Math.max(0, gate.auxiliaryInFlight - 1);
|
|
1079
|
+
if (gate.auxiliaryInFlight === 0)
|
|
1080
|
+
for (const resolve of gate.idleWaiters.splice(0))
|
|
1081
|
+
resolve();
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
auxiliaryMembraneFor(agentName) {
|
|
1085
|
+
const target = this.membrane;
|
|
1086
|
+
return new Proxy(target, { get: (obj, prop, receiver) => {
|
|
1087
|
+
const value = Reflect.get(obj, prop, receiver);
|
|
1088
|
+
if (prop === 'complete' && typeof value === 'function') {
|
|
1089
|
+
return (...args) => this.withAuxiliaryAdmission(agentName, () => Reflect.apply(value, this.membrane, args));
|
|
1090
|
+
}
|
|
1091
|
+
return typeof value === 'function' ? value.bind(this.membrane) : value;
|
|
1092
|
+
} });
|
|
1093
|
+
}
|
|
1094
|
+
accelerationCooldownMs(agentName, error) {
|
|
1095
|
+
const base = error.retryAfterMs ?? this.providerAccelerationDefaultCooldownMs;
|
|
1096
|
+
let hash = 0;
|
|
1097
|
+
for (const ch of agentName)
|
|
1098
|
+
hash = ((hash * 31) + ch.charCodeAt(0)) >>> 0;
|
|
1099
|
+
const jitter = this.providerAccelerationJitterMs > 0 ? hash % (this.providerAccelerationJitterMs + 1) : 0;
|
|
1100
|
+
return Math.min(PROVIDER_ACCELERATION_MAX_COOLDOWN_MS, Math.max(1_000, base) + jitter);
|
|
1101
|
+
}
|
|
1102
|
+
sameInferenceRequest(a, b) {
|
|
1103
|
+
return a.agentName === b.agentName && a.reason === b.reason && a.source === b.source &&
|
|
1104
|
+
a.timestamp === b.timestamp && a.channelId === b.channelId;
|
|
1105
|
+
}
|
|
1106
|
+
holdProviderAcceleration(agent, error, trigger) {
|
|
1107
|
+
if (this.ephemeralRuns.has(agent.name) || this.conversationAgentHomes.has(agent.name) || !isOrganizationAccelerationRateLimit(error))
|
|
1108
|
+
return false;
|
|
1109
|
+
const now = Date.now();
|
|
1110
|
+
const delayMs = this.accelerationCooldownMs(agent.name, error);
|
|
1111
|
+
const existing = this.providerAccelerationCooldowns.get(agent.name);
|
|
1112
|
+
const held = existing?.heldRequests ?? [];
|
|
1113
|
+
if (trigger && !held.some((r) => this.sameInferenceRequest(r, trigger)))
|
|
1114
|
+
held.push(trigger);
|
|
1115
|
+
if (existing)
|
|
1116
|
+
clearTimeout(existing.timer);
|
|
1117
|
+
const timer = setTimeout(() => this.releaseProviderAccelerationCooldown(agent.name), delayMs);
|
|
1118
|
+
timer.unref?.();
|
|
1119
|
+
this.providerAccelerationCooldowns.set(agent.name, { startedAt: existing?.startedAt ?? now,
|
|
1120
|
+
until: now + delayMs, timer, heldRequests: held, reason: error.message,
|
|
1121
|
+
failures: (existing?.failures ?? 0) + 1 });
|
|
1122
|
+
const gate = this.providerGate(agent.name);
|
|
1123
|
+
gate.primaryPending = true;
|
|
1124
|
+
this.providerAccelerationRecoveries.set(agent.name, { startedAt: existing?.startedAt ?? now,
|
|
1125
|
+
failures: (existing?.failures ?? 0) + 1, heldRequests: held.length, reason: error.message });
|
|
1126
|
+
console.error(`[provider-cooldown] agent=${agent.name} organization acceleration 429 — ` +
|
|
1127
|
+
`holding primary/auxiliary for ${delayMs}ms; ${held.length} request(s) retained`);
|
|
1128
|
+
return true;
|
|
1129
|
+
}
|
|
1130
|
+
releaseProviderAccelerationCooldown(agentName) {
|
|
1131
|
+
const cooldown = this.providerAccelerationCooldowns.get(agentName);
|
|
1132
|
+
if (!cooldown)
|
|
1133
|
+
return;
|
|
1134
|
+
clearTimeout(cooldown.timer);
|
|
1135
|
+
this.providerAccelerationCooldowns.delete(agentName);
|
|
1136
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
1137
|
+
if (recovery) {
|
|
1138
|
+
recovery.releasedAt = Date.now();
|
|
1139
|
+
recovery.heldRequests = cooldown.heldRequests.length;
|
|
1140
|
+
}
|
|
1141
|
+
const requests = cooldown.heldRequests.length > 0 ? cooldown.heldRequests : [{ agentName,
|
|
1142
|
+
reason: 'provider-acceleration-retry', source: 'framework', timestamp: Date.now() }];
|
|
1143
|
+
this.pendingRequests.push(...requests);
|
|
1144
|
+
console.error(`[provider-cooldown] agent=${agentName} released after ${Date.now() - cooldown.startedAt}ms — ` +
|
|
1145
|
+
`fresh compile queued with ${requests.length} retained request(s)`);
|
|
1146
|
+
}
|
|
1147
|
+
recordProviderAccelerationRecovery(agent, request, stopReason) {
|
|
1148
|
+
const recovery = this.providerAccelerationRecoveries.get(agent.name);
|
|
1149
|
+
if (!recovery?.releasedAt)
|
|
1150
|
+
return;
|
|
1151
|
+
this.providerAccelerationRecoveries.delete(agent.name);
|
|
1152
|
+
const completedAt = Date.now();
|
|
1153
|
+
const receipt = {
|
|
1154
|
+
startedAt: recovery.startedAt,
|
|
1155
|
+
releasedAt: recovery.releasedAt,
|
|
1156
|
+
completedAt,
|
|
1157
|
+
cooldownMs: recovery.releasedAt - recovery.startedAt,
|
|
1158
|
+
waitedMs: completedAt - recovery.startedAt,
|
|
1159
|
+
failures: recovery.failures,
|
|
1160
|
+
heldRequests: recovery.heldRequests,
|
|
1161
|
+
messageCount: request?.messages.length ?? 0,
|
|
1162
|
+
toolCount: request?.tools?.length ?? 0,
|
|
1163
|
+
stopReason,
|
|
1164
|
+
};
|
|
1165
|
+
this.providerAccelerationLastRecovery.set(agent.name, receipt);
|
|
1166
|
+
// Operational receipt only: provider admission must never author resident memory.
|
|
1167
|
+
console.error(`[provider-cooldown] recovered ${JSON.stringify({ agent: agent.name, ...receipt })}`);
|
|
1168
|
+
}
|
|
1169
|
+
cancelProviderAdmission() {
|
|
1170
|
+
for (const cooldown of this.providerAccelerationCooldowns.values())
|
|
1171
|
+
clearTimeout(cooldown.timer);
|
|
1172
|
+
this.providerAccelerationCooldowns.clear();
|
|
1173
|
+
this.providerAccelerationRecoveries.clear();
|
|
1174
|
+
for (const gate of this.providerGates.values()) {
|
|
1175
|
+
gate.primaryDepth = 0;
|
|
1176
|
+
gate.primaryPending = false;
|
|
1177
|
+
for (const resolve of gate.auxiliaryWaiters.splice(0))
|
|
1178
|
+
resolve();
|
|
1179
|
+
for (const resolve of gate.idleWaiters.splice(0))
|
|
1180
|
+
resolve();
|
|
1181
|
+
}
|
|
1182
|
+
this.providerGates.clear();
|
|
1183
|
+
}
|
|
991
1184
|
startQueuedMaintenance() {
|
|
992
1185
|
if (this.maintenancePass || !this.running)
|
|
993
1186
|
return;
|
|
@@ -1007,6 +1200,8 @@ export class AgentFramework {
|
|
|
1007
1200
|
const cm = agent.getContextManager();
|
|
1008
1201
|
const tools = this.getToolsForAgent(agent.name).filter((tool) => agent.canUseTool(tool.name));
|
|
1009
1202
|
cm.setToolDefinitions(tools);
|
|
1203
|
+
if (this.providerGateBlocked(agent.name))
|
|
1204
|
+
return [];
|
|
1010
1205
|
if (cm.isReady())
|
|
1011
1206
|
return [];
|
|
1012
1207
|
const pending = cm.getPendingWork()?.description;
|
|
@@ -1570,8 +1765,10 @@ export class AgentFramework {
|
|
|
1570
1765
|
/**
|
|
1571
1766
|
* Add or replace (upsert) a gate policy at runtime. Validated and persisted
|
|
1572
1767
|
* to gate.json; hot-applied in memory. Throws if no gate is configured or the
|
|
1573
|
-
* policy is invalid.
|
|
1574
|
-
*
|
|
1768
|
+
* policy is invalid. Placement: `'prepend'` puts a rule ahead of EVERYTHING
|
|
1769
|
+
* (including addressed-message wake rules — see EventGate.lintShadows);
|
|
1770
|
+
* `{before: name}` / `{after: name}` anchor it against a specific rule,
|
|
1771
|
+
* which is usually what a sampler or debounce actually wants.
|
|
1575
1772
|
*/
|
|
1576
1773
|
addGatePolicy(rawPolicy, options) {
|
|
1577
1774
|
if (!this.eventGate) {
|
|
@@ -1904,9 +2101,17 @@ export class AgentFramework {
|
|
|
1904
2101
|
name: 'wake_add_rule',
|
|
1905
2102
|
description: 'Add or replace a wake rule (a gate.json policy) at runtime — no need to ' +
|
|
1906
2103
|
'hand-edit the file. The rule is validated and hot-applied immediately. ' +
|
|
1907
|
-
'A rule with the same `name` replaces the existing one in place
|
|
1908
|
-
'
|
|
1909
|
-
'
|
|
2104
|
+
'A rule with the same `name` replaces the existing one in place (add a ' +
|
|
2105
|
+
'position to move it). ⚠️ ORDERING IS SEMANTICS: first match wins, and a ' +
|
|
2106
|
+
'matched rule CONSUMES the event — a broad rule placed early makes every ' +
|
|
2107
|
+
'later rule unreachable for its scope, including your addressed-message ' +
|
|
2108
|
+
'(DM/mention) wake rules. Prefer `insertBefore`/`insertAfter` to place a ' +
|
|
2109
|
+
'rule relative to a specific existing rule; use `position: "prepend"` only ' +
|
|
2110
|
+
'when the rule really must beat EVERYTHING. For "additionally wake me ' +
|
|
2111
|
+
'every Nth event / at most N per hour" governors, set `passthrough: true` ' +
|
|
2112
|
+
'so non-firing matches fall through to your normal rules instead of ' +
|
|
2113
|
+
'swallowing them. The result includes a before/after probe table and any ' +
|
|
2114
|
+
'shadow warnings — READ THEM before ending the turn. Two common shapes:\n' +
|
|
1910
2115
|
'• Watch a FILE/workspace path: match on `mount` + `pathGlob`, e.g. ' +
|
|
1911
2116
|
'`{ name: "watch-notes", match: { scope: ["workspace:modified"], mount: "project", pathGlob: "notes/*.md" }, behavior: "always" }`.\n' +
|
|
1912
2117
|
'• Watch a CHANNEL: match on `source` + `channel` (and/or `tagsAny: ["chat:ambient"]`), ' +
|
|
@@ -1973,7 +2178,27 @@ export class AgentFramework {
|
|
|
1973
2178
|
position: {
|
|
1974
2179
|
type: 'string',
|
|
1975
2180
|
enum: ['append', 'prepend'],
|
|
1976
|
-
description: '
|
|
2181
|
+
description: 'Coarse placement. "append" (default) adds at the end; "prepend" puts the rule ' +
|
|
2182
|
+
'ahead of EVERYTHING — including addressed-message wake rules — so prefer ' +
|
|
2183
|
+
'insertBefore/insertAfter unless the rule really must beat every other rule. ' +
|
|
2184
|
+
'When replacing an existing name, giving any placement MOVES the rule there; ' +
|
|
2185
|
+
'omit all placement fields to update it in place.',
|
|
2186
|
+
},
|
|
2187
|
+
insertBefore: {
|
|
2188
|
+
type: 'string',
|
|
2189
|
+
description: 'Insert the rule immediately BEFORE the named existing rule (error if absent). ' +
|
|
2190
|
+
'The safe way to order against a specific catch-all, e.g. insertBefore: "discord-ambient".',
|
|
2191
|
+
},
|
|
2192
|
+
insertAfter: {
|
|
2193
|
+
type: 'string',
|
|
2194
|
+
description: 'Insert the rule immediately AFTER the named existing rule (error if absent).',
|
|
2195
|
+
},
|
|
2196
|
+
passthrough: {
|
|
2197
|
+
type: 'boolean',
|
|
2198
|
+
description: 'Observer semantics for rateLimit/passiveSample rules: when the rule matches but ' +
|
|
2199
|
+
'does not fire (below N, bucket empty), the event FALLS THROUGH to later rules ' +
|
|
2200
|
+
'instead of being consumed. Use for "additionally wake me every Nth event" ' +
|
|
2201
|
+
'governors so they never swallow DM/mention wakes.',
|
|
1977
2202
|
},
|
|
1978
2203
|
resets: {
|
|
1979
2204
|
type: 'array',
|
|
@@ -3063,7 +3288,9 @@ export class AgentFramework {
|
|
|
3063
3288
|
store: this.store,
|
|
3064
3289
|
namespace: `agents/${config.name}`,
|
|
3065
3290
|
strategy: config.strategy ?? new PassthroughStrategy(),
|
|
3066
|
-
|
|
3291
|
+
// Context Manager complete() calls are auxiliary work; the primary
|
|
3292
|
+
// streaming agent keeps the original Membrane.
|
|
3293
|
+
membrane: this.auxiliaryMembraneFor(config.name),
|
|
3067
3294
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3068
3295
|
});
|
|
3069
3296
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
@@ -3290,7 +3517,7 @@ export class AgentFramework {
|
|
|
3290
3517
|
// classifier-safe D-class from the 2026-07-24 ablation). It also
|
|
3291
3518
|
// updates lastAnnouncedLocus so the next turn's announce-on-change
|
|
3292
3519
|
// diffs against what the agent was actually last told.
|
|
3293
|
-
if (agent.proseRouting
|
|
3520
|
+
if ((agent.proseRouting === 'locus' || agent.proseRouting === 'hybrid') &&
|
|
3294
3521
|
!shouldEndTurn && !overBudget && currentState.stream) {
|
|
3295
3522
|
// Two signals qualify an injection to move the pin (n=6 + n=7):
|
|
3296
3523
|
// - chat:addressed — someone explicitly spoke TO the agent
|
|
@@ -3693,6 +3920,9 @@ export class AgentFramework {
|
|
|
3693
3920
|
isolate: true,
|
|
3694
3921
|
// Strategy instances are stateful — never share the template's.
|
|
3695
3922
|
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
3923
|
+
// Dynamic conversation forks retain the established provider policy in
|
|
3924
|
+
// this bounded first slice. Provider cooldown ownership belongs to the
|
|
3925
|
+
// persistent resident only; generation-unique forks must not leak gates.
|
|
3696
3926
|
membrane: this.membrane,
|
|
3697
3927
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
3698
3928
|
});
|
|
@@ -4026,7 +4256,8 @@ export class AgentFramework {
|
|
|
4026
4256
|
// Clear pending (we'll re-add if inference doesn't run)
|
|
4027
4257
|
this.pendingRequests = [];
|
|
4028
4258
|
// Check each agent
|
|
4029
|
-
for (const [agentName,
|
|
4259
|
+
for (const [agentName, groupedRequests] of requestsByAgent) {
|
|
4260
|
+
let requests = groupedRequests;
|
|
4030
4261
|
const agent = this.agents.get(agentName);
|
|
4031
4262
|
if (!agent) {
|
|
4032
4263
|
// Agent not found — request is orphaned. Emit warning and drop.
|
|
@@ -4041,6 +4272,29 @@ export class AgentFramework {
|
|
|
4041
4272
|
console.error(`[inference-dropped] agent=${agentName} reason=agent_not_found requests=${requests.length}`);
|
|
4042
4273
|
continue;
|
|
4043
4274
|
}
|
|
4275
|
+
const providerCooldown = this.providerAccelerationCooldowns?.get(agentName);
|
|
4276
|
+
if (providerCooldown) {
|
|
4277
|
+
if (now < providerCooldown.until) {
|
|
4278
|
+
for (const req of requests) {
|
|
4279
|
+
if (!providerCooldown.heldRequests.some((r) => this.sameInferenceRequest(r, req))) {
|
|
4280
|
+
providerCooldown.heldRequests.push(req);
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
4284
|
+
if (recovery)
|
|
4285
|
+
recovery.heldRequests = providerCooldown.heldRequests.length;
|
|
4286
|
+
// The timer owns release. Do not hot-poll these requests.
|
|
4287
|
+
continue;
|
|
4288
|
+
}
|
|
4289
|
+
// Timers can deliver late under load. Release synchronously and merge
|
|
4290
|
+
// the held causes into this one current compile.
|
|
4291
|
+
clearTimeout(providerCooldown.timer);
|
|
4292
|
+
this.providerAccelerationCooldowns.delete(agentName);
|
|
4293
|
+
const recovery = this.providerAccelerationRecoveries.get(agentName);
|
|
4294
|
+
if (recovery)
|
|
4295
|
+
recovery.releasedAt = now;
|
|
4296
|
+
requests = [...providerCooldown.heldRequests, ...requests];
|
|
4297
|
+
}
|
|
4044
4298
|
// Skip if agent is busy (inferring, streaming, or waiting for tools) —
|
|
4045
4299
|
// or if a turn is alive at all (activeTurnTokens): the state machine
|
|
4046
4300
|
// goes idle at endTurn's reset while driveStream teardown is still
|
|
@@ -4056,9 +4310,14 @@ export class AgentFramework {
|
|
|
4056
4310
|
// token; the predecessor frame's late finally no-ops via token-match.
|
|
4057
4311
|
// This mirrors how the restart has always overwritten activeStreams
|
|
4058
4312
|
// rather than waiting for the old stream's teardown.
|
|
4059
|
-
const
|
|
4060
|
-
const turnAlive = !
|
|
4061
|
-
|
|
4313
|
+
const budgetRestart = requests.find((r) => r.reason === 'context_budget_restart');
|
|
4314
|
+
const turnAlive = !budgetRestart && this.activeTurnTokens.has(agentName);
|
|
4315
|
+
const providerGate = this.providerGates?.get(agentName);
|
|
4316
|
+
// A primary can own provider admission while yielding to an already in-flight
|
|
4317
|
+
// auxiliary call. Keep this agent's later wakes queued, but do not block the
|
|
4318
|
+
// framework event loop or other residents while that auxiliary call settles.
|
|
4319
|
+
const providerPrimaryWaiting = (providerGate?.primaryDepth ?? 0) > 0 && !this.activeTurnTokens.has(agentName);
|
|
4320
|
+
if (providerPrimaryWaiting || turnAlive || agent.state.status === 'inferring' || agent.state.status === 'streaming' || agent.state.status === 'waiting_for_tools') {
|
|
4062
4321
|
// Re-queue requests, but warn if they've been pending too long
|
|
4063
4322
|
const oldest = Math.min(...requests.map(r => r.timestamp));
|
|
4064
4323
|
if (now - oldest > STALE_REQUEST_MS &&
|
|
@@ -4095,10 +4354,21 @@ export class AgentFramework {
|
|
|
4095
4354
|
// exactly this class of silent drop. One stderr line per drop.
|
|
4096
4355
|
console.error(`[inference-dropped] agent=${agentName} reason=policy-skip ` +
|
|
4097
4356
|
`requests=${requests.length} triggers=${requests.map((r) => r.reason).join(',')}`);
|
|
4357
|
+
const gate = this.providerGates.get(agentName);
|
|
4358
|
+
if (gate?.primaryPending && this.providerAccelerationRecoveries.has(agentName)) {
|
|
4359
|
+
gate.primaryPending = false;
|
|
4360
|
+
this.providerAccelerationRecoveries.delete(agentName);
|
|
4361
|
+
this.flushAuxiliaryAdmission(agentName);
|
|
4362
|
+
}
|
|
4098
4363
|
continue;
|
|
4099
4364
|
}
|
|
4100
4365
|
// Start streaming inference (non-blocking — driveStream runs in background)
|
|
4101
|
-
|
|
4366
|
+
// The request that grants the turn-alive exception must also define the
|
|
4367
|
+
// downstream turn semantics. An ordinary wake can already be pending
|
|
4368
|
+
// when the tool boundary appends a context-budget restart; selecting
|
|
4369
|
+
// requests[0] in that mixed batch bypassed the turn lock because a
|
|
4370
|
+
// restart existed, then treated the continuation as a fresh turn.
|
|
4371
|
+
const trigger = budgetRestart ?? requests[0];
|
|
4102
4372
|
// Route this turn's auto-published speech to the channel that triggered
|
|
4103
4373
|
// it (item-3 redux). A batched wake may carry several triggering channels
|
|
4104
4374
|
// (messages arrived in >1 channel while the agent was busy/idle):
|
|
@@ -4177,7 +4447,9 @@ export class AgentFramework {
|
|
|
4177
4447
|
: id);
|
|
4178
4448
|
}
|
|
4179
4449
|
const suppressedNote = suppressed > 0
|
|
4180
|
-
?
|
|
4450
|
+
? agent.proseRouting === 'disabled'
|
|
4451
|
+
? `${suppressed} plain-speech segment(s) suppressed (proseRouting=disabled — publish only with an explicit send tool)`
|
|
4452
|
+
: `${suppressed} plain-speech segment(s) suppressed (explicit send in the same round — resend with a send tool if it was meant to be heard)`
|
|
4181
4453
|
: '';
|
|
4182
4454
|
const text = shown.length > 0
|
|
4183
4455
|
? `[delivered] plain speech → ${shown.join(' · ')}${suppressedNote ? ` · ${suppressedNote}` : ''}`
|
|
@@ -4307,6 +4579,98 @@ export class AgentFramework {
|
|
|
4307
4579
|
await this.deliverProseEnvelope(agent, envelope.replace(/^\s+(?=>>)/, ''));
|
|
4308
4580
|
}
|
|
4309
4581
|
}
|
|
4582
|
+
/** Locus-preserving explicit publication. Source remains byte-identical in Chronicle. */
|
|
4583
|
+
async deliverHybridProse(agent, rawText, locus, allowLocus) {
|
|
4584
|
+
const lines = rawText.split('\n');
|
|
4585
|
+
const envelopes = [];
|
|
4586
|
+
let current = [];
|
|
4587
|
+
for (const line of lines) {
|
|
4588
|
+
if (line.trimStart().startsWith('>>>') && current.length > 0) {
|
|
4589
|
+
envelopes.push(current.join('\n'));
|
|
4590
|
+
current = [];
|
|
4591
|
+
}
|
|
4592
|
+
current.push(line);
|
|
4593
|
+
}
|
|
4594
|
+
if (current.length > 0)
|
|
4595
|
+
envelopes.push(current.join('\n'));
|
|
4596
|
+
for (const envelope of envelopes) {
|
|
4597
|
+
if (!envelope.trim())
|
|
4598
|
+
continue;
|
|
4599
|
+
const normalized = envelope.replace(/^\s+(?=>>>)/, '');
|
|
4600
|
+
const attempted = normalized.startsWith('>>>');
|
|
4601
|
+
const parsed = parseHybridProsePrefix(normalized);
|
|
4602
|
+
if (parsed.continueTurn)
|
|
4603
|
+
this.proseContinuations.add(agent.name);
|
|
4604
|
+
if (parsed.kind === 'private') {
|
|
4605
|
+
this.proseTargetPins.delete(agent.name);
|
|
4606
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4607
|
+
console.error(`[prose] ${agent.name}: >>>skip_reply — ${parsed.body.length} chars kept in context (not sent)`);
|
|
4608
|
+
continue;
|
|
4609
|
+
}
|
|
4610
|
+
if (parsed.kind === 'target') {
|
|
4611
|
+
const resolved = this.channelRegistry.resolveProseTarget(parsed.target);
|
|
4612
|
+
if ('error' in resolved) {
|
|
4613
|
+
this.proseTargetPins.delete(agent.name);
|
|
4614
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4615
|
+
this.bounceProse(agent, parsed.body, resolved.error, resolved.candidates, '>>>');
|
|
4616
|
+
continue;
|
|
4617
|
+
}
|
|
4618
|
+
let body = parsed.body;
|
|
4619
|
+
const usedClipboard = body.includes('{{unsent}}');
|
|
4620
|
+
if (usedClipboard)
|
|
4621
|
+
body = body.replaceAll('{{unsent}}', this.proseClipboards.get(agent.name) ?? '');
|
|
4622
|
+
this.proseTargetPins.set(agent.name, resolved.channelId);
|
|
4623
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
4624
|
+
if (!body.trim()) {
|
|
4625
|
+
console.error(`[prose] ${agent.name}: >>>${parsed.target} established target; empty body — nothing sent yet`);
|
|
4626
|
+
continue;
|
|
4627
|
+
}
|
|
4628
|
+
try {
|
|
4629
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, body, resolved.channelId);
|
|
4630
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4631
|
+
if (outcome?.delivered) {
|
|
4632
|
+
this.proseBounceStreaks.delete(agent.name);
|
|
4633
|
+
if (usedClipboard)
|
|
4634
|
+
this.proseClipboards.delete(agent.name);
|
|
4635
|
+
}
|
|
4636
|
+
else {
|
|
4637
|
+
this.proseTargetPins.delete(agent.name);
|
|
4638
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4639
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} was not confirmed`, undefined, '>>>');
|
|
4640
|
+
}
|
|
4641
|
+
}
|
|
4642
|
+
catch (err) {
|
|
4643
|
+
this.proseTargetPins.delete(agent.name);
|
|
4644
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4645
|
+
this.bounceProse(agent, parsed.body, `delivery to ${resolved.channelId} failed: ${err instanceof Error ? err.message : String(err)}`, undefined, '>>>');
|
|
4646
|
+
}
|
|
4647
|
+
continue;
|
|
4648
|
+
}
|
|
4649
|
+
if (attempted) {
|
|
4650
|
+
this.proseTargetPins.delete(agent.name);
|
|
4651
|
+
this.proseHybridSuppressed.add(agent.name);
|
|
4652
|
+
this.bounceProse(agent, normalized, 'malformed >>> routing envelope: destination is missing', undefined, '>>>');
|
|
4653
|
+
continue;
|
|
4654
|
+
}
|
|
4655
|
+
if (this.proseHybridSuppressed.has(agent.name)) {
|
|
4656
|
+
this.recordProseSuppression(agent.name, 1);
|
|
4657
|
+
continue;
|
|
4658
|
+
}
|
|
4659
|
+
const sticky = this.proseTargetPins.get(agent.name);
|
|
4660
|
+
if (!allowLocus && !sticky) {
|
|
4661
|
+
this.recordProseSuppression(agent.name, 1);
|
|
4662
|
+
continue;
|
|
4663
|
+
}
|
|
4664
|
+
const target = sticky ?? locus;
|
|
4665
|
+
try {
|
|
4666
|
+
const outcome = await this.channelRegistry.routeSpeech(agent.name, envelope, target);
|
|
4667
|
+
this.recordProseDelivery(agent.name, outcome);
|
|
4668
|
+
}
|
|
4669
|
+
catch (err) {
|
|
4670
|
+
console.error('hybrid locus delivery failed:', err);
|
|
4671
|
+
}
|
|
4672
|
+
}
|
|
4673
|
+
}
|
|
4310
4674
|
async deliverProseEnvelope(agent, rawText) {
|
|
4311
4675
|
const name = agent.name;
|
|
4312
4676
|
const parsed = parseProsePrefix(rawText);
|
|
@@ -4365,7 +4729,7 @@ export class AgentFramework {
|
|
|
4365
4729
|
}
|
|
4366
4730
|
/** Cap on consecutive bounce-triggered wakes (notices still append after). */
|
|
4367
4731
|
static PROSE_BOUNCE_WAKE_CAP = 2;
|
|
4368
|
-
bounceProse(agent, text, reason, candidates) {
|
|
4732
|
+
bounceProse(agent, text, reason, candidates, prefix = '>>') {
|
|
4369
4733
|
const name = agent.name;
|
|
4370
4734
|
this.proseClipboards.set(name, text);
|
|
4371
4735
|
const streak = (this.proseBounceStreaks.get(name) ?? 0) + 1;
|
|
@@ -4373,8 +4737,9 @@ export class AgentFramework {
|
|
|
4373
4737
|
const cand = candidates?.length ? ` Known channels it could mean: ${candidates.join(', ')}.` : '';
|
|
4374
4738
|
const notice = `[prose-routing] Your text (${text.length} chars) was not delivered — ${reason}.${cand} ` +
|
|
4375
4739
|
'The text is retained; nothing is lost. To deliver it unchanged, reply with a ' +
|
|
4376
|
-
|
|
4377
|
-
|
|
4740
|
+
`destination plus the token {{unsent}}, e.g. "${prefix}#channel {{unsent}}" or "${prefix}@person {{unsent}}". ` +
|
|
4741
|
+
`"${prefix}skip_reply {{unsent}}" keeps it in context only.` +
|
|
4742
|
+
(prefix === '>>' ? ' The prose_help tool shows the full syntax.' : '');
|
|
4378
4743
|
try {
|
|
4379
4744
|
// Through framework.addMessage, NOT the context manager directly: while
|
|
4380
4745
|
// the turn is still streaming this defers the notice to the next tool
|
|
@@ -4437,6 +4802,13 @@ export class AgentFramework {
|
|
|
4437
4802
|
console.error('maybePrimeProseMode: state read/write failed:', err);
|
|
4438
4803
|
return;
|
|
4439
4804
|
}
|
|
4805
|
+
// Disabled mode is intentionally silent: no model-visible routing prose.
|
|
4806
|
+
// The resident publishes only through explicit tools. This contains
|
|
4807
|
+
// continuity-summary locus leaks (Host #96).
|
|
4808
|
+
if (mode === 'disabled') {
|
|
4809
|
+
console.error(`[prose] ${agent.name}: disabled mode recorded (no primer injected)`);
|
|
4810
|
+
return;
|
|
4811
|
+
}
|
|
4440
4812
|
try {
|
|
4441
4813
|
const id = agent.getContextManager().addMessage('user', [{ type: 'text', text: proseModePrimer(mode) }], { system: true, kind: 'prose-routing-primer' });
|
|
4442
4814
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'prose-routing-primer' });
|
|
@@ -4446,7 +4818,32 @@ export class AgentFramework {
|
|
|
4446
4818
|
console.error('maybePrimeProseMode: failed to append primer:', err);
|
|
4447
4819
|
}
|
|
4448
4820
|
}
|
|
4449
|
-
async startAgentStream(agent, trigger, attempt = 0) {
|
|
4821
|
+
async startAgentStream(agent, trigger, attempt = 0, providerGateAlreadyHeld = false) {
|
|
4822
|
+
const ownsProviderGate = !this.ephemeralRuns.has(agent.name) && !this.conversationAgentHomes.has(agent.name);
|
|
4823
|
+
if (ownsProviderGate && !providerGateAlreadyHeld) {
|
|
4824
|
+
this.acquirePrimaryProviderGate(agent.name);
|
|
4825
|
+
const gate = this.providerGate(agent.name);
|
|
4826
|
+
if (gate.auxiliaryInFlight > 0) {
|
|
4827
|
+
// Do not await inside processInferenceRequests: one slow auxiliary call for
|
|
4828
|
+
// this resident must not block events or other residents. Admission stays
|
|
4829
|
+
// owned and the continuation receives it after the in-flight call settles.
|
|
4830
|
+
void this.waitForAuxiliaryIdle(agent.name).then(async () => {
|
|
4831
|
+
if (this.providerAdmissionClosed) {
|
|
4832
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
4833
|
+
return;
|
|
4834
|
+
}
|
|
4835
|
+
await this.startAgentStream(agent, trigger, attempt, true);
|
|
4836
|
+
}).catch((error) => {
|
|
4837
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
4838
|
+
console.error(`[provider-cooldown] failed to resume primary for ${agent.name}:`, error);
|
|
4839
|
+
});
|
|
4840
|
+
return;
|
|
4841
|
+
}
|
|
4842
|
+
if (this.providerAdmissionClosed) {
|
|
4843
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
4844
|
+
return;
|
|
4845
|
+
}
|
|
4846
|
+
}
|
|
4450
4847
|
// Mark the turn alive before ANYTHING awaits (hooks, compile, stream
|
|
4451
4848
|
// setup): from here until this turn's teardown, cross-turn writers defer
|
|
4452
4849
|
// (addMessage guard) instead of appending — nothing may enter the window
|
|
@@ -4467,12 +4864,14 @@ export class AgentFramework {
|
|
|
4467
4864
|
this.activeTurnTokens.set(agent.name, turnToken);
|
|
4468
4865
|
let tokenHandedOff = false;
|
|
4469
4866
|
try {
|
|
4470
|
-
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken);
|
|
4867
|
+
tokenHandedOff = await this.beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate);
|
|
4471
4868
|
}
|
|
4472
4869
|
finally {
|
|
4473
4870
|
if (!tokenHandedOff && this.activeTurnTokens.get(agent.name) === turnToken) {
|
|
4474
4871
|
this.activeTurnTokens.delete(agent.name);
|
|
4475
4872
|
}
|
|
4873
|
+
if (!tokenHandedOff && ownsProviderGate)
|
|
4874
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
4476
4875
|
}
|
|
4477
4876
|
}
|
|
4478
4877
|
/**
|
|
@@ -4480,7 +4879,7 @@ export class AgentFramework {
|
|
|
4480
4879
|
* turn token unconditionally. Returns true iff the token was handed off to
|
|
4481
4880
|
* driveStream (whose finally then owns clearing it).
|
|
4482
4881
|
*/
|
|
4483
|
-
async beginAgentTurn(agent, trigger, attempt, turnToken) {
|
|
4882
|
+
async beginAgentTurn(agent, trigger, attempt, turnToken, ownsProviderGate) {
|
|
4484
4883
|
// Flush messages deferred during the PREVIOUS turn — before the
|
|
4485
4884
|
// checkpoint, the locus announcement, and the compile — so a turn started
|
|
4486
4885
|
// by a queued wake actually CONTAINS the message that woke it. (2026-07-31
|
|
@@ -4563,10 +4962,13 @@ export class AgentFramework {
|
|
|
4563
4962
|
this.turnEngagedChannels.delete(agent.name);
|
|
4564
4963
|
this.turnProseDeliveries.delete(agent.name);
|
|
4565
4964
|
this.turnProseSuppressed.delete(agent.name);
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
|
|
4965
|
+
this.proseHybridSuppressed.delete(agent.name);
|
|
4966
|
+
if (agent.proseRouting === 'hybrid')
|
|
4967
|
+
this.proseTargetPins.delete(agent.name);
|
|
4968
|
+
if (agent.proseRouting === 'explicit' || agent.proseRouting === 'disabled') {
|
|
4969
|
+
// Explicit and disabled prose routing have no inferred locus.
|
|
4970
|
+
// Explicit mode uses a turn-scoped `>>` target; disabled mode has no
|
|
4971
|
+
// prose target at all. Neither mode freezes or announces a locus.
|
|
4570
4972
|
this.proseTargetPins.delete(agent.name);
|
|
4571
4973
|
this.proseContinuations.delete(agent.name);
|
|
4572
4974
|
this.midTurnInputSignals.delete(agent.name);
|
|
@@ -4646,7 +5048,7 @@ export class AgentFramework {
|
|
|
4646
5048
|
}
|
|
4647
5049
|
}
|
|
4648
5050
|
const { stream, request: compiledRequest } = await agent.startStreamWithInjections(tools, injections);
|
|
4649
|
-
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest);
|
|
5051
|
+
const handle = this.driveStream(agent, stream, requestSnapshot, trigger, attempt, compiledRequest, ownsProviderGate);
|
|
4650
5052
|
this.activeStreams.set(agent.name, handle);
|
|
4651
5053
|
// Handoff: driveStream captured the token in its synchronous prefix;
|
|
4652
5054
|
// its finally now owns clearing it. The caller's finally must not.
|
|
@@ -4665,6 +5067,12 @@ export class AgentFramework {
|
|
|
4665
5067
|
stack: err.stack,
|
|
4666
5068
|
});
|
|
4667
5069
|
agent.reset();
|
|
5070
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5071
|
+
// Capacity scheduling is not poisoned history. The cooldown timer owns
|
|
5072
|
+
// one later fresh compile; no immediate retry or exhaustion marker.
|
|
5073
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5074
|
+
return false;
|
|
5075
|
+
}
|
|
4668
5076
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
4669
5077
|
if (action.retry) {
|
|
4670
5078
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
@@ -4712,7 +5120,7 @@ export class AgentFramework {
|
|
|
4712
5120
|
return false;
|
|
4713
5121
|
}
|
|
4714
5122
|
}
|
|
4715
|
-
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest) {
|
|
5123
|
+
async driveStream(agent, stream, requestSnapshot, trigger, attempt = 0, compiledRequest, ownsProviderGate = false) {
|
|
4716
5124
|
const startTime = Date.now();
|
|
4717
5125
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
4718
5126
|
const myStreamId = agent.streamId;
|
|
@@ -4769,9 +5177,11 @@ export class AgentFramework {
|
|
|
4769
5177
|
// this doesn't violate never-guess: it says "attending to what you
|
|
4770
5178
|
// sent here", which is true regardless of where the reply goes.
|
|
4771
5179
|
// Heartbeat/no-trigger explicit turns show no indicator.
|
|
4772
|
-
const typingChannel = agent.proseRouting === '
|
|
4773
|
-
?
|
|
4774
|
-
:
|
|
5180
|
+
const typingChannel = agent.proseRouting === 'disabled'
|
|
5181
|
+
? null
|
|
5182
|
+
: agent.proseRouting === 'explicit'
|
|
5183
|
+
? trigger?.channelId ?? null
|
|
5184
|
+
: resolveTurnLocus();
|
|
4775
5185
|
if (typingChannel)
|
|
4776
5186
|
this.channelRegistry.startTyping(typingChannel);
|
|
4777
5187
|
// MCPL Spec 14.3 outgoing streaming: route text deltas to their
|
|
@@ -4799,9 +5209,9 @@ export class AgentFramework {
|
|
|
4799
5209
|
turnIndex: 0,
|
|
4800
5210
|
phase: 'started',
|
|
4801
5211
|
});
|
|
4802
|
-
const proseStream = this.channelRegistry
|
|
5212
|
+
const proseStream = this.channelRegistry && agent.proseRouting !== 'disabled'
|
|
4803
5213
|
? new ProseStreamRouter({
|
|
4804
|
-
mode: agent.proseRouting === 'explicit' ? 'explicit' : 'locus',
|
|
5214
|
+
mode: agent.proseRouting === 'explicit' ? 'explicit' : agent.proseRouting === 'hybrid' ? 'hybrid' : 'locus',
|
|
4805
5215
|
initialTarget: typingChannel,
|
|
4806
5216
|
resolve: (spec) => {
|
|
4807
5217
|
const r = this.channelRegistry.resolveProseTarget(spec);
|
|
@@ -4917,9 +5327,17 @@ export class AgentFramework {
|
|
|
4917
5327
|
const rawXml = rawText && /^<(antml:)?function_calls>/.test(rawText)
|
|
4918
5328
|
? rawText
|
|
4919
5329
|
: undefined;
|
|
5330
|
+
// Persist THIS round's text only. `roundPreamble` (membrane
|
|
5331
|
+
// ≥0.5.79) is the per-round delta; the legacy `preamble` is
|
|
5332
|
+
// CUMULATIVE in XML mode — persisting it per round stored each
|
|
5333
|
+
// round's text N times and re-persisted injected
|
|
5334
|
+
// <function_results> as the agent's own words (the Evander
|
|
5335
|
+
// 2026-08-08 scaffold-leak pyramid: r1 ×3, r2 ×2).
|
|
5336
|
+
const roundText = event.context.roundPreamble ??
|
|
5337
|
+
event.context.preamble;
|
|
4920
5338
|
assistantBlocks = [];
|
|
4921
|
-
if (
|
|
4922
|
-
assistantBlocks.push({ type: 'text', text:
|
|
5339
|
+
if (roundText) {
|
|
5340
|
+
assistantBlocks.push({ type: 'text', text: roundText });
|
|
4923
5341
|
}
|
|
4924
5342
|
for (const c of event.calls) {
|
|
4925
5343
|
assistantBlocks.push({
|
|
@@ -4956,14 +5374,32 @@ export class AgentFramework {
|
|
|
4956
5374
|
const roundToolNames = event.calls.map((c) => c.name);
|
|
4957
5375
|
const hasSameRoundPrivateThink = roundToolNames.includes('think') &&
|
|
4958
5376
|
requestSnapshot.sameRoundThinkTextPolicy === 'private';
|
|
4959
|
-
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n))
|
|
5377
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
5378
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))))) {
|
|
4960
5379
|
turnSilenced = true;
|
|
4961
5380
|
}
|
|
4962
5381
|
if (roundContent && roundContent.length > 0) {
|
|
4963
5382
|
liveProseRouting = true;
|
|
4964
5383
|
const roundSegments = splitProseSegments(assistantBlocks);
|
|
4965
5384
|
if (roundSegments.length > 0) {
|
|
4966
|
-
if (agent.proseRouting === '
|
|
5385
|
+
if (agent.proseRouting === 'disabled') {
|
|
5386
|
+
console.error(`[routing] ${agent.name}: mid-turn prose NOT routed (proseRouting=disabled)`);
|
|
5387
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5388
|
+
}
|
|
5389
|
+
else if (agent.proseRouting === 'hybrid') {
|
|
5390
|
+
if (turnSilenced) {
|
|
5391
|
+
this.recordProseSuppression(agent.name, roundSegments.length);
|
|
5392
|
+
}
|
|
5393
|
+
else if (!hasSameRoundPrivateThink) {
|
|
5394
|
+
const locus = resolveTurnLocus();
|
|
5395
|
+
for (const seg of roundSegments) {
|
|
5396
|
+
turnSpeechChain = turnSpeechChain
|
|
5397
|
+
.then(() => this.deliverHybridProse(agent, seg, locus, true))
|
|
5398
|
+
.catch((err) => console.error('mid-turn hybrid prose delivery failed:', err));
|
|
5399
|
+
}
|
|
5400
|
+
}
|
|
5401
|
+
}
|
|
5402
|
+
else if (agent.proseRouting === 'explicit') {
|
|
4967
5403
|
// Explicit mode: every segment through the prose gateway.
|
|
4968
5404
|
// Silencing does not apply — unprefixed prose bounces and
|
|
4969
5405
|
// prefixed prose is deliberate; think-privacy still holds.
|
|
@@ -5051,6 +5487,9 @@ export class AgentFramework {
|
|
|
5051
5487
|
else {
|
|
5052
5488
|
agent.addAssistantResponse(terminalContent);
|
|
5053
5489
|
}
|
|
5490
|
+
// Bind the cooldown receipt to this fresh, successful request —
|
|
5491
|
+
// never to the frozen request that received the 429.
|
|
5492
|
+
this.recordProviderAccelerationRecovery(agent, compiledRequest, response.stopReason);
|
|
5054
5493
|
// §10.5: context/afterInference is removed in 0.5.0 — no
|
|
5055
5494
|
// content leaves the host here. Turn boundaries are announced
|
|
5056
5495
|
// by the metadata-only inference/lifecycle notifications
|
|
@@ -5243,7 +5682,21 @@ export class AgentFramework {
|
|
|
5243
5682
|
.join('\n')
|
|
5244
5683
|
.trim();
|
|
5245
5684
|
if (speechText) {
|
|
5246
|
-
if (agent.proseRouting === '
|
|
5685
|
+
if (agent.proseRouting === 'disabled') {
|
|
5686
|
+
console.error(`[routing] ${agent.name}: text-only prose NOT routed (proseRouting=disabled)`);
|
|
5687
|
+
this.recordProseSuppression(agent.name, 1);
|
|
5688
|
+
}
|
|
5689
|
+
else if (agent.proseRouting === 'hybrid') {
|
|
5690
|
+
const locus = resolveTurnLocus();
|
|
5691
|
+
console.error(`[prose] ${agent.name}: text-only turn -> hybrid prose gateway`);
|
|
5692
|
+
try {
|
|
5693
|
+
await this.deliverHybridProse(agent, speechText, locus, true);
|
|
5694
|
+
}
|
|
5695
|
+
catch (err) {
|
|
5696
|
+
console.error('text-only hybrid prose delivery failed:', err);
|
|
5697
|
+
}
|
|
5698
|
+
}
|
|
5699
|
+
else if (agent.proseRouting === 'explicit') {
|
|
5247
5700
|
console.error(`[prose] ${agent.name}: text-only turn -> prose gateway`);
|
|
5248
5701
|
try {
|
|
5249
5702
|
await this.deliverProse(agent, speechText);
|
|
@@ -5292,13 +5745,36 @@ export class AgentFramework {
|
|
|
5292
5745
|
.filter((n) => typeof n === 'string');
|
|
5293
5746
|
const silenced = liveProseRouting
|
|
5294
5747
|
? turnSilenced
|
|
5295
|
-
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n))
|
|
5748
|
+
: turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)) ||
|
|
5749
|
+
(agent.proseRouting === 'hybrid' && HYBRID_PUBLICATION_TOOLS.has(bareToolName(n))));
|
|
5296
5750
|
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
5297
5751
|
// Preserve in-channel ordering: everything enqueued live must
|
|
5298
5752
|
// land before the trailing prose. Awaited even when silenced —
|
|
5299
5753
|
// the chain may still be flushing earlier rounds' posts.
|
|
5300
5754
|
await turnSpeechChain;
|
|
5301
|
-
if (agent.proseRouting === '
|
|
5755
|
+
if (agent.proseRouting === 'disabled') {
|
|
5756
|
+
if (segments.length > 0) {
|
|
5757
|
+
console.error(`[routing] ${agent.name}: tool-call trailing prose NOT routed (proseRouting=disabled)`);
|
|
5758
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
5759
|
+
}
|
|
5760
|
+
}
|
|
5761
|
+
else if (agent.proseRouting === 'hybrid') {
|
|
5762
|
+
if (silenced && segments.length > 0) {
|
|
5763
|
+
this.recordProseSuppression(agent.name, segments.length);
|
|
5764
|
+
}
|
|
5765
|
+
else if (segments.length > 0) {
|
|
5766
|
+
const locus = resolveTurnLocus();
|
|
5767
|
+
for (const seg of segments) {
|
|
5768
|
+
try {
|
|
5769
|
+
await this.deliverHybridProse(agent, seg, locus, true);
|
|
5770
|
+
}
|
|
5771
|
+
catch (err) {
|
|
5772
|
+
console.error('trailing hybrid prose delivery failed:', err);
|
|
5773
|
+
}
|
|
5774
|
+
}
|
|
5775
|
+
}
|
|
5776
|
+
}
|
|
5777
|
+
else if (agent.proseRouting === 'explicit') {
|
|
5302
5778
|
if (segments.length > 0) {
|
|
5303
5779
|
console.error(`[prose] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> ${segments.length} trailing segment(s) via prose gateway`);
|
|
5304
5780
|
for (const seg of segments) {
|
|
@@ -5390,6 +5866,11 @@ export class AgentFramework {
|
|
|
5390
5866
|
break;
|
|
5391
5867
|
this.abortAgentScript(agent.name, 'stream error');
|
|
5392
5868
|
agent.reset();
|
|
5869
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
5870
|
+
lifecyclePhase = 'failed';
|
|
5871
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
5872
|
+
break;
|
|
5873
|
+
}
|
|
5393
5874
|
const action = this.errorPolicy.onInferenceError(err, agent.name, attempt);
|
|
5394
5875
|
if (action.retry) {
|
|
5395
5876
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
@@ -5523,10 +6004,21 @@ export class AgentFramework {
|
|
|
5523
6004
|
}
|
|
5524
6005
|
}
|
|
5525
6006
|
catch (error) {
|
|
5526
|
-
// Stream itself threw
|
|
5527
|
-
//
|
|
6007
|
+
// Stream itself threw. Organization acceleration is deferred from a
|
|
6008
|
+
// fresh compile; every other throw keeps the ordinary exhausted path.
|
|
5528
6009
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
5529
6010
|
const durationMs = Date.now() - startTime;
|
|
6011
|
+
if (ownsProviderGate && this.holdProviderAcceleration(agent, err, trigger)) {
|
|
6012
|
+
this.emitTrace({ type: 'inference:failed', agentName: agent.name, error: err.message, stack: err.stack });
|
|
6013
|
+
this.logInference({ timestamp: startTime, agentName: agent.name, requestId, success: false,
|
|
6014
|
+
error: `Provider acceleration cooldown: ${err.message}`,
|
|
6015
|
+
request: compiledRequest ?? { note: 'streaming request rate-limited' }, durationMs });
|
|
6016
|
+
this.abortAgentScript(agent.name, 'provider acceleration cooldown');
|
|
6017
|
+
lifecyclePhase = 'failed';
|
|
6018
|
+
agent.reset();
|
|
6019
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
6020
|
+
return;
|
|
6021
|
+
}
|
|
5530
6022
|
this.emitTrace({
|
|
5531
6023
|
type: 'inference:failed',
|
|
5532
6024
|
agentName: agent.name,
|
|
@@ -5582,6 +6074,8 @@ export class AgentFramework {
|
|
|
5582
6074
|
// wedge). onInferenceEnded is idempotent, so a redundant call is safe.
|
|
5583
6075
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
5584
6076
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), endedAt: Date.now() });
|
|
6077
|
+
if (ownsProviderGate)
|
|
6078
|
+
this.releasePrimaryProviderGate(agent.name);
|
|
5585
6079
|
// Stop the typing indicator on every exit path (complete, error,
|
|
5586
6080
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
5587
6081
|
this.channelRegistry?.stopTyping();
|
|
@@ -5640,6 +6134,91 @@ export class AgentFramework {
|
|
|
5640
6134
|
agentName: call.callerAgentName ?? '__ephemeral__',
|
|
5641
6135
|
});
|
|
5642
6136
|
}
|
|
6137
|
+
/**
|
|
6138
|
+
* Admin puppet: execute ONE tool call AS an agent and persist the
|
|
6139
|
+
* tool_use + tool_result pair in that agent's window exactly as a
|
|
6140
|
+
* model-initiated call is stored.
|
|
6141
|
+
*
|
|
6142
|
+
* Born from the princess exemplar surgery (2026-08-23). Her folded
|
|
6143
|
+
* narrative said "I have no tools"; every past tool_use/tool_result pair
|
|
6144
|
+
* had folded out of her context, so the act of calling had no in-context
|
|
6145
|
+
* exemplar and she could not emit her first call despite 30 eidoverse
|
|
6146
|
+
* tools riding every request. Placing one first-person pair restored the
|
|
6147
|
+
* capacity. That repair required stop → store surgery → restart; this
|
|
6148
|
+
* method is the live path — and the general lever for demonstrating an
|
|
6149
|
+
* affordance to a model that cannot find it (older models especially).
|
|
6150
|
+
*
|
|
6151
|
+
* Semantics:
|
|
6152
|
+
* - Refused unless the agent is idle: puppeting mid-turn would corrupt
|
|
6153
|
+
* the turn state machine and the live stream's wire ordering.
|
|
6154
|
+
* - Refused for tools outside the agent's own surface (canUseTool): the
|
|
6155
|
+
* stored pair must be an act the agent could genuinely have taken.
|
|
6156
|
+
* - The call executes FOR REAL through the shared dispatch (MCPL,
|
|
6157
|
+
* channel tools, utils, modules) with the agent's provenance — a
|
|
6158
|
+
* puppeted walk_to actually moves the avatar.
|
|
6159
|
+
* - Storage byte-follows the ordinary path: a bare tool_use assistant
|
|
6160
|
+
* message (no fabricated thinking or text — a stored assistant turn
|
|
6161
|
+
* with unverifiable thinking is the labclaude 400 class) plus a
|
|
6162
|
+
* tool_result under the same bounded spill policy (issue #89).
|
|
6163
|
+
* - Does NOT request inference; the agent sees the pair on its next
|
|
6164
|
+
* wake. Speak to the agent afterward if a wake is wanted.
|
|
6165
|
+
* - Provenance is loud in traces (puppet:tool-call) and the host log,
|
|
6166
|
+
* deliberately NOT in the stored messages — metadata there would break
|
|
6167
|
+
* byte-parity with real turns. Whether to disclose to the resident is
|
|
6168
|
+
* the operator's call; the princess precedent was disclosed first.
|
|
6169
|
+
*/
|
|
6170
|
+
async puppetToolCall(agentName, toolName, input) {
|
|
6171
|
+
const agent = this.agents.get(agentName);
|
|
6172
|
+
if (!agent)
|
|
6173
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
6174
|
+
if (agent.state.status !== 'idle') {
|
|
6175
|
+
throw new Error(`puppet refused: agent ${agentName} is ${agent.state.status} (requires idle — ` +
|
|
6176
|
+
`injecting a turn under an active stream corrupts wire ordering)`);
|
|
6177
|
+
}
|
|
6178
|
+
const onSurface = this.getToolsForAgent(agentName)
|
|
6179
|
+
.some((t) => t.name === toolName && agent.canUseTool(t.name));
|
|
6180
|
+
if (!onSurface) {
|
|
6181
|
+
throw new Error(`puppet refused: tool ${toolName} is not on ${agentName}'s surface — ` +
|
|
6182
|
+
`the stored pair must be an act the agent could genuinely have taken`);
|
|
6183
|
+
}
|
|
6184
|
+
// Anthropic-shaped id so the stored pair is indistinguishable from a
|
|
6185
|
+
// provider-issued call.
|
|
6186
|
+
const alphabet = 'ABCDEFGHJKMNPQRSTVWXYZabcdefghjkmnpqrstvwxyz0123456789';
|
|
6187
|
+
let suffix = '';
|
|
6188
|
+
for (let i = 0; i < 22; i++)
|
|
6189
|
+
suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
|
|
6190
|
+
const toolUseId = `toolu_01${suffix}`;
|
|
6191
|
+
const started = Date.now();
|
|
6192
|
+
const result = await this.executeToolCall({
|
|
6193
|
+
id: toolUseId,
|
|
6194
|
+
name: toolName,
|
|
6195
|
+
input,
|
|
6196
|
+
callerAgentName: agentName,
|
|
6197
|
+
});
|
|
6198
|
+
const durationMs = Date.now() - started;
|
|
6199
|
+
// Store the pair through the same shapes the ordinary path uses. Build
|
|
6200
|
+
// the result blocks BEFORE storing the tool_use: the spill path awaits,
|
|
6201
|
+
// and a message arriving during that await must land before the pair,
|
|
6202
|
+
// never between tool_use and its tool_result. The two addMessage calls
|
|
6203
|
+
// below are synchronous and adjacent — nothing can interleave.
|
|
6204
|
+
const { blocks } = await this.buildStoredToolResultContent([{ id: toolUseId, name: toolName, input, result, durationMs }], this.resolveToolResultInlineCap(agent).cap);
|
|
6205
|
+
const cm = agent.getContextManager();
|
|
6206
|
+
cm.addMessage(agentName, [
|
|
6207
|
+
{ type: 'tool_use', id: toolUseId, name: toolName, input },
|
|
6208
|
+
]);
|
|
6209
|
+
cm.addMessage('user', blocks);
|
|
6210
|
+
this.emitTrace({
|
|
6211
|
+
type: 'puppet:tool-call',
|
|
6212
|
+
agentName,
|
|
6213
|
+
toolName,
|
|
6214
|
+
toolUseId,
|
|
6215
|
+
isError: !!result.isError,
|
|
6216
|
+
durationMs,
|
|
6217
|
+
});
|
|
6218
|
+
console.log(`[puppet] ${agentName}: ${toolName} → ${result.isError ? 'ERROR' : 'ok'} ` +
|
|
6219
|
+
`in ${durationMs}ms (${toolUseId})`);
|
|
6220
|
+
return { toolUseId, result };
|
|
6221
|
+
}
|
|
5643
6222
|
async executeToolCallFrom(call, origin) {
|
|
5644
6223
|
// Client-side programmatic tool calling for promise-based callers
|
|
5645
6224
|
// (SubagentModule ephemerals). Keyed by callerAgentName so each ephemeral
|
|
@@ -6082,6 +6661,9 @@ export class AgentFramework {
|
|
|
6082
6661
|
const blocks = toolResults.map(tc => ({
|
|
6083
6662
|
type: 'tool_result',
|
|
6084
6663
|
toolUseId: tc.id,
|
|
6664
|
+
// Persisted so XML replay can reconstruct the legacy <tool_name>
|
|
6665
|
+
// element byte-identically to the live injection.
|
|
6666
|
+
toolName: tc.name,
|
|
6085
6667
|
content: spilled.get(tc.id).text,
|
|
6086
6668
|
isError: tc.result.isError,
|
|
6087
6669
|
}));
|
|
@@ -6316,7 +6898,7 @@ export class AgentFramework {
|
|
|
6316
6898
|
/**
|
|
6317
6899
|
* Effective tool-result inline cap for an agent, with provenance. Desired
|
|
6318
6900
|
* value: resident's durable agent_settings value → residence
|
|
6319
|
-
* FrameworkConfig.toolResultInlineMaxChars → house default (
|
|
6901
|
+
* FrameworkConfig.toolResultInlineMaxChars → house default (24000).
|
|
6320
6902
|
* Effective value: min(desired, strategy bound) for EVERY source — the
|
|
6321
6903
|
* strategy's per-message safety limit is a ceiling, not a suggestion
|
|
6322
6904
|
* (Sol's #94 ruling: a durable preference must not be a durable path for
|
|
@@ -6759,6 +7341,19 @@ export class AgentFramework {
|
|
|
6759
7341
|
name,
|
|
6760
7342
|
status: agent.state.status,
|
|
6761
7343
|
consecutiveInferenceFailures: this.consecutiveInferenceFailures.get(name) ?? 0,
|
|
7344
|
+
providerAdmission: (() => {
|
|
7345
|
+
const gate = this.providerGates?.get(name);
|
|
7346
|
+
const cooldown = this.providerAccelerationCooldowns?.get(name);
|
|
7347
|
+
return {
|
|
7348
|
+
primaryActive: (gate?.primaryDepth ?? 0) > 0,
|
|
7349
|
+
primaryPending: gate?.primaryPending ?? false,
|
|
7350
|
+
auxiliaryInFlight: gate?.auxiliaryInFlight ?? 0,
|
|
7351
|
+
auxiliaryDeferred: gate?.deferredAuxiliary ?? 0,
|
|
7352
|
+
cooldownUntil: cooldown?.until ?? null,
|
|
7353
|
+
heldRequests: cooldown?.heldRequests.length ?? 0,
|
|
7354
|
+
lastRecovery: this.providerAccelerationLastRecovery?.get(name) ?? null,
|
|
7355
|
+
};
|
|
7356
|
+
})(),
|
|
6762
7357
|
lastInference: this.lastInferenceAt.get(name) ?? null,
|
|
6763
7358
|
refusalStats: this.refusalStats.get(name) ?? null,
|
|
6764
7359
|
})),
|
|
@@ -8426,7 +9021,7 @@ export class AgentFramework {
|
|
|
8426
9021
|
if (opened) {
|
|
8427
9022
|
this.activeTriggerChannels.set(agentName, opened);
|
|
8428
9023
|
const openerAgent = this.agents.get(agentName);
|
|
8429
|
-
if (openerAgent && openerAgent.proseRouting
|
|
9024
|
+
if (openerAgent && (openerAgent.proseRouting === 'locus' || openerAgent.proseRouting === 'hybrid')) {
|
|
8430
9025
|
this.turnLocusPins.set(agentName, opened);
|
|
8431
9026
|
this.lastAnnouncedLocus.set(agentName, opened);
|
|
8432
9027
|
result = {
|
|
@@ -8520,17 +9115,70 @@ export class AgentFramework {
|
|
|
8520
9115
|
: input.rateLimit !== undefined ? { rate_limit: input.rateLimit }
|
|
8521
9116
|
: input.passiveSample !== undefined ? { passive_sample: input.passiveSample }
|
|
8522
9117
|
: input.behavior;
|
|
8523
|
-
|
|
9118
|
+
// Placement: at most one of position / insertBefore / insertAfter.
|
|
9119
|
+
// With none given, a replaced rule stays where it was and a new rule
|
|
9120
|
+
// appends (addPolicy's contract).
|
|
9121
|
+
const placementSources = [
|
|
9122
|
+
input.position !== undefined ? 'position' : null,
|
|
9123
|
+
input.insertBefore !== undefined ? 'insertBefore' : null,
|
|
9124
|
+
input.insertAfter !== undefined ? 'insertAfter' : null,
|
|
9125
|
+
].filter((s) => s !== null);
|
|
9126
|
+
if (placementSources.length > 1) {
|
|
9127
|
+
finish({
|
|
9128
|
+
success: false,
|
|
9129
|
+
error: `wake_add_rule: give at most one placement, got [${placementSources.join(', ')}].`,
|
|
9130
|
+
isError: true,
|
|
9131
|
+
});
|
|
9132
|
+
return;
|
|
9133
|
+
}
|
|
9134
|
+
let position;
|
|
9135
|
+
if (typeof input.insertBefore === 'string' && input.insertBefore) {
|
|
9136
|
+
position = { before: input.insertBefore };
|
|
9137
|
+
}
|
|
9138
|
+
else if (typeof input.insertAfter === 'string' && input.insertAfter) {
|
|
9139
|
+
position = { after: input.insertAfter };
|
|
9140
|
+
}
|
|
9141
|
+
else if (input.position === 'prepend' || input.position === 'append') {
|
|
9142
|
+
position = input.position;
|
|
9143
|
+
}
|
|
8524
9144
|
const rawPolicy = {
|
|
8525
9145
|
name: input.name,
|
|
8526
9146
|
match: input.match ?? {},
|
|
8527
9147
|
behavior,
|
|
9148
|
+
...(input.passthrough !== undefined ? { passthrough: input.passthrough } : {}),
|
|
8528
9149
|
...(input.resets !== undefined ? { resets: input.resets } : {}),
|
|
8529
9150
|
};
|
|
8530
|
-
|
|
9151
|
+
// Before/after probe table + shadow lint: the moment a rule changes the
|
|
9152
|
+
// evaluation order is the moment its real semantics must be legible
|
|
9153
|
+
// (af#105 — a prepended catch-all sampler silently disabled the
|
|
9154
|
+
// addressed-message wakes for 12h before anyone could see why).
|
|
9155
|
+
const before = new Map(this.eventGate.probeTable().map((r) => [r.probe, r]));
|
|
9156
|
+
const policy = this.addGatePolicy(rawPolicy, position ? { position } : undefined);
|
|
9157
|
+
const after = this.eventGate.probeTable();
|
|
9158
|
+
const probes = after.map((r) => {
|
|
9159
|
+
const prev = before.get(r.probe);
|
|
9160
|
+
const changed = !!prev && (prev.policy !== r.policy || prev.wouldWake !== r.wouldWake);
|
|
9161
|
+
return {
|
|
9162
|
+
probe: r.probe,
|
|
9163
|
+
rule: r.policy ?? '(default)',
|
|
9164
|
+
behavior: r.behavior,
|
|
9165
|
+
wouldWake: r.wouldWake,
|
|
9166
|
+
...(changed ? { changed: true, was: `${prev.policy ?? '(default)'} (wouldWake=${prev.wouldWake})` } : {}),
|
|
9167
|
+
};
|
|
9168
|
+
});
|
|
9169
|
+
const shadowWarnings = this.eventGate.lintShadows()
|
|
9170
|
+
.map((w) => formatShadowWarning(w));
|
|
8531
9171
|
finish({
|
|
8532
9172
|
success: true,
|
|
8533
|
-
data: {
|
|
9173
|
+
data: {
|
|
9174
|
+
added: policy.name,
|
|
9175
|
+
policies: this.getGatePolicyNames(),
|
|
9176
|
+
...(shadowWarnings.length > 0 ? { shadowWarnings } : {}),
|
|
9177
|
+
probes,
|
|
9178
|
+
...(probes.some((p) => 'changed' in p)
|
|
9179
|
+
? {}
|
|
9180
|
+
: { 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.' }),
|
|
9181
|
+
},
|
|
8534
9182
|
});
|
|
8535
9183
|
}
|
|
8536
9184
|
catch (err) {
|
|
@@ -8572,7 +9220,10 @@ export class AgentFramework {
|
|
|
8572
9220
|
if (announce && this.channelRegistry) {
|
|
8573
9221
|
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
8574
9222
|
const agent = this.agents.get(agentName);
|
|
8575
|
-
if (agent?.proseRouting === '
|
|
9223
|
+
if (agent?.proseRouting === 'disabled') {
|
|
9224
|
+
console.error(`[sleep] ${agentName}: proseRouting=disabled — sleep announcement not posted`);
|
|
9225
|
+
}
|
|
9226
|
+
else if (agent?.proseRouting === 'explicit') {
|
|
8576
9227
|
// Explicit mode: announce to the turn's sticky prose target if the
|
|
8577
9228
|
// model has set one; otherwise stay quiet — never guess a channel.
|
|
8578
9229
|
const target = this.proseTargetPins.get(agentName);
|