@animalabs/agent-framework 0.6.4 → 0.6.6
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/README.md +5 -0
- package/dist/src/agent.d.ts +18 -1
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +221 -2
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +59 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +687 -229
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +27 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +34 -15
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +299 -67
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts +1 -0
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +1 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +3 -1
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +4 -0
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +43 -12
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +1 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/types/agent.d.ts +15 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/events.d.ts +1 -0
- package/dist/src/types/events.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +9 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +2 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/activation-request-sanitize.test.d.ts +2 -0
- package/dist/test/activation-request-sanitize.test.d.ts.map +1 -0
- package/dist/test/activation-request-sanitize.test.js +89 -0
- package/dist/test/activation-request-sanitize.test.js.map +1 -0
- package/dist/test/agent-runtime-settings.test.d.ts +2 -0
- package/dist/test/agent-runtime-settings.test.d.ts.map +1 -0
- package/dist/test/agent-runtime-settings.test.js +120 -0
- package/dist/test/agent-runtime-settings.test.js.map +1 -0
- package/dist/test/channel-registry-routing.test.js +5 -4
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/conversation-fork-injection-scoping.test.js +12 -1
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -1
- package/dist/test/ephemeral-settle.test.d.ts +2 -0
- package/dist/test/ephemeral-settle.test.d.ts.map +1 -0
- package/dist/test/ephemeral-settle.test.js +351 -0
- package/dist/test/ephemeral-settle.test.js.map +1 -0
- package/dist/test/event-gate.test.js +21 -1
- package/dist/test/event-gate.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +13 -1
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -1
- package/dist/test/helpers/mock-membrane.js +8 -1
- package/dist/test/helpers/mock-membrane.js.map +1 -1
- package/dist/test/maintenance-timer.test.js +52 -1
- package/dist/test/maintenance-timer.test.js.map +1 -1
- package/dist/test/mcpl-gate-roundtrip.test.js +138 -6
- package/dist/test/mcpl-gate-roundtrip.test.js.map +1 -1
- package/dist/test/overbudget-drain-breaker.test.d.ts +2 -0
- package/dist/test/overbudget-drain-breaker.test.d.ts.map +1 -0
- package/dist/test/overbudget-drain-breaker.test.js +157 -0
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -0
- package/dist/test/present-while-acting.test.d.ts +18 -0
- package/dist/test/present-while-acting.test.d.ts.map +1 -0
- package/dist/test/present-while-acting.test.js +267 -0
- package/dist/test/present-while-acting.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +38 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/src/agent.ts +244 -3
- package/src/framework.ts +763 -235
- package/src/gate/event-gate.ts +38 -4
- package/src/mcpl/channel-registry.ts +372 -76
- package/src/mcpl/push-handler.ts +2 -0
- package/src/mcpl/server-connection.ts +10 -0
- package/src/mcpl/types.ts +49 -12
- package/src/types/agent.ts +18 -0
- package/src/types/events.ts +1 -1
- package/src/types/framework.ts +10 -0
- package/src/types/trace.ts +2 -0
package/dist/src/framework.js
CHANGED
|
@@ -17,6 +17,29 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
|
17
17
|
import { safeSlice } from './safe-slice.js';
|
|
18
18
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
19
19
|
import { splitProseSegments } from './prose-segments.js';
|
|
20
|
+
/**
|
|
21
|
+
* Tools whose presence suppresses auto-routing of the surrounding prose,
|
|
22
|
+
* for two distinct reasons:
|
|
23
|
+
* - `skip_reply` — the agent EXPLICITLY chose not to reply (the deliberate
|
|
24
|
+
* "stay silent" signal).
|
|
25
|
+
* - explicit delivery tools (channel_publish / *send_message /
|
|
26
|
+
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
27
|
+
* prose again would double-post.
|
|
28
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, but prose
|
|
29
|
+
* written around a think is the agent's actual voice and must be delivered.
|
|
30
|
+
*
|
|
31
|
+
* Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
|
|
32
|
+
* only by a silencing tool in that same round — live delivery can't be
|
|
33
|
+
* retracted by a later round's skip_reply); the turn's TRAILING prose keeps
|
|
34
|
+
* the historical turn-wide rule (any silencing tool anywhere in the turn
|
|
35
|
+
* suppresses it), preserving the "send_message then 'sent it.' postscript"
|
|
36
|
+
* double-post guard.
|
|
37
|
+
*/
|
|
38
|
+
const SILENCING_TOOLS = new Set([
|
|
39
|
+
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
40
|
+
]);
|
|
41
|
+
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
42
|
+
const bareToolName = (n) => n.split('--').pop();
|
|
20
43
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
21
44
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
22
45
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -146,7 +169,24 @@ export class AgentFramework {
|
|
|
146
169
|
processLoggingPersist;
|
|
147
170
|
processLoggingBroadcast;
|
|
148
171
|
activeStreams = new Map();
|
|
172
|
+
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
173
|
+
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
174
|
+
* locals — so the pin survives a context-budget stream restart, which
|
|
175
|
+
* continues the same logical turn in a fresh driveStream. Cleared at the
|
|
176
|
+
* start of every non-restart turn. */
|
|
177
|
+
turnLocusPins = new Map();
|
|
149
178
|
pendingAssistantBlocks = new Map();
|
|
179
|
+
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
180
|
+
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
181
|
+
* restart. The membrane still delivers an `aborted` event for these, and
|
|
182
|
+
* without the marker the abort handler would treat it as a terminal
|
|
183
|
+
* failure — settling ephemerals with a rejection and emitting a spurious
|
|
184
|
+
* `inference:exhausted` (which also pollutes the failure streak). Kept
|
|
185
|
+
* separate from ephemeralRuns deliberately: endTurn/budget cancels happen
|
|
186
|
+
* for resident agents too, and the key is per-stream, not per-agent. */
|
|
187
|
+
frameworkCancelledStreams = new Map();
|
|
188
|
+
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
189
|
+
ephemeralRuns = new Map();
|
|
150
190
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
151
191
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
152
192
|
consecutiveInferenceFailures = new Map();
|
|
@@ -175,6 +215,13 @@ export class AgentFramework {
|
|
|
175
215
|
* Bounds the automatic quarantine loop the same way refusalRewinds bounds
|
|
176
216
|
* the refusal loop, so the breaker can never shed the whole history. */
|
|
177
217
|
exhaustionRewinds = new Map();
|
|
218
|
+
/** Agents with an OverBudget drain kick currently in flight. The breaker in
|
|
219
|
+
* noteInferenceExhausted fires on EVERY matching failure and the scenario it
|
|
220
|
+
* exists for is "every activation fails, repeatedly" — without this guard,
|
|
221
|
+
* overlapping kicks race the strategy's own pendingCompression gate and the
|
|
222
|
+
* tick counts in the success log stop meaning anything. One kick per agent
|
|
223
|
+
* at a time; cleared when the kick settles (success or failure). */
|
|
224
|
+
overBudgetDrainInFlight = new Set();
|
|
178
225
|
/** Per-agent current rewind episode: the single consolidated marker's id and
|
|
179
226
|
* how many turns have been shed so far. One marker per episode, updated in
|
|
180
227
|
* place (see updateRewindMarker); cleared when the episode ends. */
|
|
@@ -537,7 +584,9 @@ export class AgentFramework {
|
|
|
537
584
|
if (this.maintenancePass || !this.running)
|
|
538
585
|
return;
|
|
539
586
|
const pass = this.runQueuedMaintenance().catch((error) => {
|
|
540
|
-
|
|
587
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
588
|
+
console.error('[context-maintenance] pass failed:', reason);
|
|
589
|
+
this.opsAlert('context-maintenance-failed', 'framework', reason, { data: { scope: 'pass' } });
|
|
541
590
|
});
|
|
542
591
|
this.maintenancePass = pass;
|
|
543
592
|
void pass.finally(() => {
|
|
@@ -588,6 +637,13 @@ export class AgentFramework {
|
|
|
588
637
|
catch (error) {
|
|
589
638
|
record.error = error instanceof Error ? error.message : String(error);
|
|
590
639
|
console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
|
|
640
|
+
this.opsAlert('context-maintenance-failed', agent.name, record.error, {
|
|
641
|
+
data: {
|
|
642
|
+
scope: 'agent',
|
|
643
|
+
...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
|
|
644
|
+
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
645
|
+
},
|
|
646
|
+
});
|
|
591
647
|
}
|
|
592
648
|
finally {
|
|
593
649
|
const pending = cm.getPendingWork()?.description;
|
|
@@ -725,10 +781,43 @@ export class AgentFramework {
|
|
|
725
781
|
AgentFramework.EVENT_TAGS_TOOL,
|
|
726
782
|
]
|
|
727
783
|
: [];
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
784
|
+
return [
|
|
785
|
+
...moduleTools,
|
|
786
|
+
...this.mcplTools,
|
|
787
|
+
...channelTools,
|
|
788
|
+
...gateTools,
|
|
789
|
+
AgentFramework.AGENT_SETTINGS_TOOL,
|
|
790
|
+
];
|
|
791
|
+
}
|
|
792
|
+
getAgentRuntimeSettings(agentName) {
|
|
793
|
+
const agent = this.agents.get(agentName);
|
|
794
|
+
if (!agent)
|
|
795
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
796
|
+
return agent.getRuntimeSettings();
|
|
797
|
+
}
|
|
798
|
+
updateAgentRuntimeSettings(agentName, patch) {
|
|
799
|
+
const agent = this.agents.get(agentName);
|
|
800
|
+
if (!agent)
|
|
801
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
802
|
+
const result = agent.updateRuntimeSettings(patch);
|
|
803
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
804
|
+
return result;
|
|
805
|
+
}
|
|
806
|
+
resetAgentRuntimeSettings(agentName, keys) {
|
|
807
|
+
const agent = this.agents.get(agentName);
|
|
808
|
+
if (!agent)
|
|
809
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
810
|
+
const result = agent.resetRuntimeSettings(keys);
|
|
811
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
812
|
+
return result;
|
|
813
|
+
}
|
|
814
|
+
cancelAgentRuntimeSettingsTransition(agentName) {
|
|
815
|
+
const agent = this.agents.get(agentName);
|
|
816
|
+
if (!agent)
|
|
817
|
+
throw new Error(`Unknown agent: ${agentName}`);
|
|
818
|
+
const result = agent.cancelRuntimeSettingsTransition();
|
|
819
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
820
|
+
return result;
|
|
732
821
|
}
|
|
733
822
|
/** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
|
|
734
823
|
getContextMaintenanceSnapshot() {
|
|
@@ -905,6 +994,29 @@ export class AgentFramework {
|
|
|
905
994
|
// Non-fatal — usage tracking is best-effort
|
|
906
995
|
}
|
|
907
996
|
}
|
|
997
|
+
readAgentRuntimeSettings(agentName) {
|
|
998
|
+
try {
|
|
999
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1000
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
1001
|
+
return stored ? { ...stored } : null;
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
persistAgentRuntimeSettings(agentName, overrides) {
|
|
1008
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1009
|
+
const state = (data && typeof data === 'object' ? data : {});
|
|
1010
|
+
const all = {
|
|
1011
|
+
...(state.agentRuntimeSettings ?? {}),
|
|
1012
|
+
};
|
|
1013
|
+
if (Object.keys(overrides).length === 0)
|
|
1014
|
+
delete all[agentName];
|
|
1015
|
+
else
|
|
1016
|
+
all[agentName] = { ...overrides };
|
|
1017
|
+
state.agentRuntimeSettings = all;
|
|
1018
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1019
|
+
}
|
|
908
1020
|
/**
|
|
909
1021
|
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
910
1022
|
*
|
|
@@ -934,6 +1046,50 @@ export class AgentFramework {
|
|
|
934
1046
|
};
|
|
935
1047
|
return { agent, contextManager, cleanup };
|
|
936
1048
|
}
|
|
1049
|
+
createDeferred() {
|
|
1050
|
+
let resolve;
|
|
1051
|
+
let reject;
|
|
1052
|
+
const promise = new Promise((res, rej) => {
|
|
1053
|
+
resolve = res;
|
|
1054
|
+
reject = rej;
|
|
1055
|
+
});
|
|
1056
|
+
return { promise, resolve, reject };
|
|
1057
|
+
}
|
|
1058
|
+
/** Liveness ping for an active ephemeral run (no-op otherwise). */
|
|
1059
|
+
touchEphemeralRun(agentName, inferenceStarted = false) {
|
|
1060
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1061
|
+
if (!run)
|
|
1062
|
+
return;
|
|
1063
|
+
if (inferenceStarted) {
|
|
1064
|
+
run.inferenceStarted = true;
|
|
1065
|
+
}
|
|
1066
|
+
run.lastActivity = Date.now();
|
|
1067
|
+
}
|
|
1068
|
+
recordEphemeralToolCalls(agentName, count) {
|
|
1069
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1070
|
+
if (!run)
|
|
1071
|
+
return;
|
|
1072
|
+
run.toolCallsCount += count;
|
|
1073
|
+
run.lastActivity = Date.now();
|
|
1074
|
+
}
|
|
1075
|
+
/** Settle an active ephemeral run (no-op for resident agents). The tool-call
|
|
1076
|
+
* count comes from the run's own counter — the single source of truth,
|
|
1077
|
+
* which unlike a per-stream local survives budget restarts. */
|
|
1078
|
+
settleAgent(agentName, outcome) {
|
|
1079
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1080
|
+
if (!run)
|
|
1081
|
+
return;
|
|
1082
|
+
run.lastActivity = Date.now();
|
|
1083
|
+
if (outcome.stopReason === 'exhausted') {
|
|
1084
|
+
run.settle.reject(new Error(outcome.error ?? 'Unknown error'));
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
run.settle.resolve({
|
|
1088
|
+
stopReason: outcome.stopReason,
|
|
1089
|
+
speech: outcome.speech,
|
|
1090
|
+
toolCallsCount: run.toolCallsCount,
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
937
1093
|
/**
|
|
938
1094
|
* Run an ephemeral agent to completion through the framework's event loop.
|
|
939
1095
|
*
|
|
@@ -944,139 +1100,73 @@ export class AgentFramework {
|
|
|
944
1100
|
* The caller provides a pre-created agent + contextManager (from createEphemeralAgent).
|
|
945
1101
|
* The task message should already be in the context manager.
|
|
946
1102
|
*/
|
|
947
|
-
async runEphemeralToCompletion(agent, contextManager) {
|
|
1103
|
+
async runEphemeralToCompletion(agent, contextManager, watchdogs) {
|
|
948
1104
|
// Register temporarily so the event loop can drive it
|
|
949
1105
|
this.agents.set(agent.name, agent);
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
clearTimeout(startupWatchdog);
|
|
969
|
-
clearInterval(completionWatchdog);
|
|
970
|
-
this.offTrace(traceListener);
|
|
971
|
-
this.agents.delete(agent.name);
|
|
972
|
-
this.evictTurnCheckpoints(agent.name);
|
|
973
|
-
};
|
|
974
|
-
// Watchdog: if inference never starts within 30s, the agent is a zombie.
|
|
975
|
-
// This catches cases where the event loop stalled, the agent was
|
|
976
|
-
// deregistered, or processInferenceRequests() skipped the request.
|
|
977
|
-
const startupWatchdog = setTimeout(() => {
|
|
978
|
-
if (!inferenceStarted && !settled) {
|
|
979
|
-
cleanup();
|
|
1106
|
+
const run = {
|
|
1107
|
+
settle: this.createDeferred(),
|
|
1108
|
+
inferenceStarted: false,
|
|
1109
|
+
lastActivity: Date.now(),
|
|
1110
|
+
toolCallsCount: 0,
|
|
1111
|
+
};
|
|
1112
|
+
this.ephemeralRuns.set(agent.name, run);
|
|
1113
|
+
const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
|
|
1114
|
+
// After inference has started, give it 15 minutes of activity-bounded
|
|
1115
|
+
// life. The stream driver refreshes the deadline as it makes progress;
|
|
1116
|
+
// sustained silence trips it.
|
|
1117
|
+
const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
|
|
1118
|
+
const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
|
|
1119
|
+
let startupWatchdog = null;
|
|
1120
|
+
let completionWatchdog = null;
|
|
1121
|
+
const startupTimeout = new Promise((_, reject) => {
|
|
1122
|
+
startupWatchdog = setTimeout(() => {
|
|
1123
|
+
if (!run.inferenceStarted) {
|
|
980
1124
|
reject(new Error(`Ephemeral agent "${agent.name}" failed to start inference within ` +
|
|
981
1125
|
`${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
|
|
982
1126
|
`stalled or the inference request was dropped.`));
|
|
983
1127
|
}
|
|
984
1128
|
}, STARTUP_TIMEOUT_MS);
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
// and keeps its concurrency slot pinned.
|
|
990
|
-
const completionWatchdog = setInterval(() => {
|
|
991
|
-
if (settled || !inferenceStarted)
|
|
1129
|
+
});
|
|
1130
|
+
const completionIdleTimeout = new Promise((_, reject) => {
|
|
1131
|
+
completionWatchdog = setInterval(() => {
|
|
1132
|
+
if (!run.inferenceStarted)
|
|
992
1133
|
return;
|
|
993
|
-
const idle = Date.now() - lastActivity;
|
|
1134
|
+
const idle = Date.now() - run.lastActivity;
|
|
994
1135
|
if (idle > COMPLETION_IDLE_TIMEOUT_MS) {
|
|
995
|
-
|
|
996
|
-
reject(new Error(`Ephemeral agent "${agent.name}" stalled: no trace activity for ` +
|
|
1136
|
+
reject(new Error(`Ephemeral agent "${agent.name}" stalled: no stream-driver activity for ` +
|
|
997
1137
|
`${Math.round(idle / 1000)}s after inference started (threshold ` +
|
|
998
1138
|
`${Math.round(COMPLETION_IDLE_TIMEOUT_MS / 1000)}s). Stream likely ` +
|
|
999
1139
|
`dropped or terminal event was lost.`));
|
|
1000
1140
|
}
|
|
1001
|
-
},
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
// Only track events for our ephemeral agent
|
|
1006
|
-
const agentName = 'agentName' in event ? event.agentName : null;
|
|
1007
|
-
if (agentName !== agent.name)
|
|
1008
|
-
return;
|
|
1009
|
-
// Any addressed event counts as liveness for the completion watchdog.
|
|
1010
|
-
lastActivity = Date.now();
|
|
1011
|
-
switch (event.type) {
|
|
1012
|
-
case 'inference:started':
|
|
1013
|
-
inferenceStarted = true;
|
|
1014
|
-
break;
|
|
1015
|
-
case 'inference:tokens': {
|
|
1016
|
-
inferenceStarted = true;
|
|
1017
|
-
const content = event.content;
|
|
1018
|
-
if (content)
|
|
1019
|
-
speech += content;
|
|
1020
|
-
break;
|
|
1021
|
-
}
|
|
1022
|
-
case 'inference:tool_calls_yielded': {
|
|
1023
|
-
const calls = event.calls;
|
|
1024
|
-
if (calls)
|
|
1025
|
-
toolCallsCount += calls.length;
|
|
1026
|
-
// Reset speech buffer — tool calls break the text
|
|
1027
|
-
speech = '';
|
|
1028
|
-
break;
|
|
1029
|
-
}
|
|
1030
|
-
case 'inference:stream_resumed':
|
|
1031
|
-
speech = '';
|
|
1032
|
-
break;
|
|
1033
|
-
case 'inference:completed': {
|
|
1034
|
-
// `inference:completed` is terminal — driveStream only emits it
|
|
1035
|
-
// from `case 'complete'`, which fires after the full tool
|
|
1036
|
-
// round-trip has finished and the model has stopped. Intermediate
|
|
1037
|
-
// tool-cycle transitions use `inference:stream_resumed`, not
|
|
1038
|
-
// `inference:completed`. So we resolve unconditionally here.
|
|
1039
|
-
//
|
|
1040
|
-
// Previously this branch gated on `agent.state.status === 'idle'`,
|
|
1041
|
-
// which was correct in intent but order-fragile: the framework
|
|
1042
|
-
// emitted `inference:completed` BEFORE calling `agent.reset()`,
|
|
1043
|
-
// so synchronous listeners observed status === 'streaming' at
|
|
1044
|
-
// the trace boundary. The gate failed silently and the promise
|
|
1045
|
-
// hung forever — every production zombie subagent originated
|
|
1046
|
-
// here. The reset+emit order has been fixed in driveStream's
|
|
1047
|
-
// `case 'complete'` handler; the gate is dropped here as
|
|
1048
|
-
// belt-and-suspenders.
|
|
1049
|
-
cleanup();
|
|
1050
|
-
resolve({ speech, toolCallsCount });
|
|
1051
|
-
break;
|
|
1052
|
-
}
|
|
1053
|
-
case 'inference:turn_ended': {
|
|
1054
|
-
// endTurn from a tool result — agent is done
|
|
1055
|
-
cleanup();
|
|
1056
|
-
resolve({ speech, toolCallsCount });
|
|
1057
|
-
break;
|
|
1058
|
-
}
|
|
1059
|
-
case 'inference:exhausted': {
|
|
1060
|
-
// Retries exhausted — reject only after the error policy gives up.
|
|
1061
|
-
// Earlier inference:failed events are left alone so the framework
|
|
1062
|
-
// retry path in driveStream/startAgentStream can restart the stream
|
|
1063
|
-
// while this listener stays alive.
|
|
1064
|
-
const error = event.error ?? 'Unknown error';
|
|
1065
|
-
cleanup();
|
|
1066
|
-
reject(new Error(error));
|
|
1067
|
-
break;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
};
|
|
1071
|
-
this.onTrace(traceListener);
|
|
1072
|
-
// Trigger inference
|
|
1141
|
+
}, IDLE_POLL_MS);
|
|
1142
|
+
});
|
|
1143
|
+
try {
|
|
1144
|
+
// Trigger inference after the settle promise is registered.
|
|
1073
1145
|
this.pendingRequests.push({
|
|
1074
1146
|
agentName: agent.name,
|
|
1075
1147
|
reason: 'ephemeral',
|
|
1076
1148
|
source: 'subagent',
|
|
1077
1149
|
timestamp: Date.now(),
|
|
1078
1150
|
});
|
|
1079
|
-
|
|
1151
|
+
const result = await Promise.race([
|
|
1152
|
+
run.settle.promise,
|
|
1153
|
+
startupTimeout,
|
|
1154
|
+
completionIdleTimeout,
|
|
1155
|
+
]);
|
|
1156
|
+
return { speech: result.speech, toolCallsCount: result.toolCallsCount };
|
|
1157
|
+
}
|
|
1158
|
+
finally {
|
|
1159
|
+
if (startupWatchdog)
|
|
1160
|
+
clearTimeout(startupWatchdog);
|
|
1161
|
+
if (completionWatchdog)
|
|
1162
|
+
clearInterval(completionWatchdog);
|
|
1163
|
+
this.ephemeralRuns.delete(agent.name);
|
|
1164
|
+
this.agents.delete(agent.name);
|
|
1165
|
+
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
1166
|
+
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1167
|
+
// entries behind for the life of the store/session.
|
|
1168
|
+
this.evictTurnCheckpoints(agent.name);
|
|
1169
|
+
}
|
|
1080
1170
|
}
|
|
1081
1171
|
/**
|
|
1082
1172
|
* Get queue depth.
|
|
@@ -1233,6 +1323,29 @@ export class AgentFramework {
|
|
|
1233
1323
|
'tagsAll / tagsNone) or in gate.js.',
|
|
1234
1324
|
inputSchema: { type: 'object' },
|
|
1235
1325
|
};
|
|
1326
|
+
static AGENT_SETTINGS_TOOL = {
|
|
1327
|
+
name: 'agent_settings',
|
|
1328
|
+
description: 'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
1329
|
+
'context budget, recent raw tail size, and transition pace; model, prompts, ' +
|
|
1330
|
+
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
1331
|
+
'Lower context budgets converge gradually under the transition pace before ' +
|
|
1332
|
+
'becoming the hard live limit; increases take effect immediately.',
|
|
1333
|
+
inputSchema: {
|
|
1334
|
+
type: 'object',
|
|
1335
|
+
properties: {
|
|
1336
|
+
action: { type: 'string', enum: ['get', 'update', 'reset', 'cancel'] },
|
|
1337
|
+
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1338
|
+
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1339
|
+
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1340
|
+
settings: {
|
|
1341
|
+
type: 'array',
|
|
1342
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
|
|
1343
|
+
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
required: ['action'],
|
|
1347
|
+
},
|
|
1348
|
+
};
|
|
1236
1349
|
/** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
|
|
1237
1350
|
* the agent can author rules without reading the spec. */
|
|
1238
1351
|
static CHAT_CORE_TAGS = {
|
|
@@ -2150,6 +2263,9 @@ export class AgentFramework {
|
|
|
2150
2263
|
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2151
2264
|
});
|
|
2152
2265
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
2266
|
+
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
2267
|
+
if (restoredSettings)
|
|
2268
|
+
agent.restoreRuntimeSettings(restoredSettings);
|
|
2153
2269
|
this.agents.set(config.name, agent);
|
|
2154
2270
|
this.agentConfigs.set(config.name, config);
|
|
2155
2271
|
// First non-ephemeral agent becomes the primary for message routing
|
|
@@ -2196,6 +2312,9 @@ export class AgentFramework {
|
|
|
2196
2312
|
// messages between tool_use and tool_result, causing a 400 error.
|
|
2197
2313
|
if (event.type === 'tool-result') {
|
|
2198
2314
|
const agent = this.agents.get(event.agentName);
|
|
2315
|
+
if (agent) {
|
|
2316
|
+
this.touchEphemeralRun(event.agentName, true);
|
|
2317
|
+
}
|
|
2199
2318
|
if (!agent) {
|
|
2200
2319
|
console.warn(`[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
|
|
2201
2320
|
`Agent may have been destroyed while tool was executing.`);
|
|
@@ -2239,16 +2358,49 @@ export class AgentFramework {
|
|
|
2239
2358
|
isError: tc.result.isError,
|
|
2240
2359
|
}));
|
|
2241
2360
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
2242
|
-
// Flush any messages that were deferred while
|
|
2243
|
-
// Route to the PRIMARY agent — deferred messages are
|
|
2244
|
-
// (
|
|
2361
|
+
// Flush any messages that were deferred while this turn was in
|
|
2362
|
+
// flight. Route to the PRIMARY agent — deferred messages are
|
|
2363
|
+
// framework-level (module messages, push events, subagent return
|
|
2364
|
+
// notifications) meant for the main conversation.
|
|
2365
|
+
//
|
|
2366
|
+
// Hear-while-acting: messages flushed into THIS agent's own window
|
|
2367
|
+
// are also collected for mid-turn injection — passed to the membrane
|
|
2368
|
+
// with the tool results below, so the NEXT inference round of the
|
|
2369
|
+
// live turn sees them instead of the agent staying deaf until the
|
|
2370
|
+
// turn ends. The window stores them here, right after the
|
|
2371
|
+
// tool_result message, which is exactly where the membrane appends
|
|
2372
|
+
// them in the live conversation — same participant, same content
|
|
2373
|
+
// blocks, same metadata, and compile() emits recent messages
|
|
2374
|
+
// verbatim, so the next turn's compiled prefix byte-matches the
|
|
2375
|
+
// live request (prompt-cache safe).
|
|
2376
|
+
//
|
|
2377
|
+
// The queue is drained ONLY at the target agent's own boundary:
|
|
2378
|
+
// draining at another agent's boundary would store the messages
|
|
2379
|
+
// mid-turn in the target's window without injecting them into the
|
|
2380
|
+
// target's live stream (deaf agent + window/live divergence). Left
|
|
2381
|
+
// queued, they flush at the target's own next boundary — with
|
|
2382
|
+
// injection — or in driveStream's finally when its turn ends.
|
|
2383
|
+
const midTurnInjections = [];
|
|
2245
2384
|
if (this.deferredMessages.length > 0) {
|
|
2246
|
-
const
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2385
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
2386
|
+
if (target === agent) {
|
|
2387
|
+
const deferred = this.deferredMessages.splice(0);
|
|
2388
|
+
for (const msg of deferred) {
|
|
2389
|
+
target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
2390
|
+
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
2391
|
+
// structure the membrane enforces, and a message named as the
|
|
2392
|
+
// agent itself would render as an ASSISTANT turn on the wire
|
|
2393
|
+
// (an unintended prefill the model would continue). Such
|
|
2394
|
+
// messages stay window-only — visible next turn.
|
|
2395
|
+
const hasToolBlocks = msg.content.some((b) => b.type === 'tool_use' || b.type === 'tool_result');
|
|
2396
|
+
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
2397
|
+
midTurnInjections.push({
|
|
2398
|
+
participant: msg.participant,
|
|
2399
|
+
content: msg.content,
|
|
2400
|
+
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
2401
|
+
});
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2252
2404
|
}
|
|
2253
2405
|
}
|
|
2254
2406
|
// Check if any tool result requested endTurn
|
|
@@ -2260,13 +2412,29 @@ export class AgentFramework {
|
|
|
2260
2412
|
if (shouldEndTurn) {
|
|
2261
2413
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
2262
2414
|
if (currentState.stream) {
|
|
2263
|
-
agent.
|
|
2415
|
+
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
|
|
2416
|
+
currentState.stream.cancel();
|
|
2264
2417
|
}
|
|
2265
2418
|
agent.reset();
|
|
2419
|
+
// The turn is over — release the gate here, not just in the abort
|
|
2420
|
+
// handler: the membrane's aborted event is asynchronous and the
|
|
2421
|
+
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
2422
|
+
// driveStream's finally is the backstop.)
|
|
2423
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2424
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
2266
2425
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
2267
2426
|
}
|
|
2268
2427
|
else if (overBudget) {
|
|
2269
|
-
// Context budget exceeded: break the stream, let compile() compress
|
|
2428
|
+
// Context budget exceeded: break the stream, let compile() compress.
|
|
2429
|
+
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
2430
|
+
// membrane delivers `aborted` before the restart bumps streamId,
|
|
2431
|
+
// and without the marker the abort handler treats the restart as
|
|
2432
|
+
// a terminal failure — rejecting an ephemeral's promise mid-run
|
|
2433
|
+
// and emitting a spurious inference:exhausted (same race shape as
|
|
2434
|
+
// endTurn above).
|
|
2435
|
+
if (currentState.stream) {
|
|
2436
|
+
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
2437
|
+
}
|
|
2270
2438
|
agent.cancelStream();
|
|
2271
2439
|
this.emitTrace({
|
|
2272
2440
|
type: 'inference:stream_restarted',
|
|
@@ -2283,11 +2451,18 @@ export class AgentFramework {
|
|
|
2283
2451
|
});
|
|
2284
2452
|
}
|
|
2285
2453
|
else if (currentState.stream) {
|
|
2286
|
-
// Streaming path: convert results and resume the stream
|
|
2454
|
+
// Streaming path: convert results and resume the stream.
|
|
2455
|
+
// Mid-turn messages collected above ride along as injected user
|
|
2456
|
+
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
2457
|
+
// envelope so the next round of THIS turn hears them.
|
|
2287
2458
|
const membraneResults = currentState.toolResults.map(tc => this.toMembraneToolResult(tc.id, tc.result, maxChars));
|
|
2288
|
-
currentState.stream.provideToolResults(membraneResults);
|
|
2459
|
+
currentState.stream.provideToolResults(membraneResults, midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined);
|
|
2289
2460
|
agent.setStreaming(currentState.stream);
|
|
2290
|
-
this.emitTrace({
|
|
2461
|
+
this.emitTrace({
|
|
2462
|
+
type: 'inference:stream_resumed',
|
|
2463
|
+
agentName: agent.name,
|
|
2464
|
+
...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
|
|
2465
|
+
});
|
|
2291
2466
|
}
|
|
2292
2467
|
else {
|
|
2293
2468
|
// Non-streaming fallback: schedule re-inference
|
|
@@ -2665,6 +2840,10 @@ export class AgentFramework {
|
|
|
2665
2840
|
* Convert an MCPL push event to a context message.
|
|
2666
2841
|
*/
|
|
2667
2842
|
handleMcplPushEvent(event) {
|
|
2843
|
+
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
2844
|
+
if (triggerChannel && this.channelRegistry) {
|
|
2845
|
+
this.channelRegistry.ensureChannelRegistered(event.serverId, triggerChannel.channelId, triggerChannel.label);
|
|
2846
|
+
}
|
|
2668
2847
|
const metadata = {
|
|
2669
2848
|
...event.origin,
|
|
2670
2849
|
serverId: event.serverId,
|
|
@@ -2672,17 +2851,36 @@ export class AgentFramework {
|
|
|
2672
2851
|
eventId: event.eventId,
|
|
2673
2852
|
triggered: event.triggerInference ?? false,
|
|
2674
2853
|
};
|
|
2675
|
-
const
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
2684
|
-
|
|
2854
|
+
const content = [...event.content];
|
|
2855
|
+
const addressedWhileClosed = triggerChannel &&
|
|
2856
|
+
event.tags?.includes('chat:addressed') &&
|
|
2857
|
+
this.channelRegistry &&
|
|
2858
|
+
!this.channelRegistry.isChannelOpen(triggerChannel.channelId);
|
|
2859
|
+
if (addressedWhileClosed) {
|
|
2860
|
+
const descriptor = this.channelRegistry.getDescriptor(triggerChannel.channelId);
|
|
2861
|
+
const messageId = typeof event.origin?.messageId === 'string'
|
|
2862
|
+
? event.origin.messageId
|
|
2863
|
+
: event.eventId;
|
|
2864
|
+
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
2865
|
+
content.push({
|
|
2866
|
+
type: 'text',
|
|
2867
|
+
text: `\n[Channel invitation] You were addressed in closed channel ` +
|
|
2868
|
+
`"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
|
|
2869
|
+
`You received this one message without subscribing. ` +
|
|
2870
|
+
(maxBackscroll > 0
|
|
2871
|
+
? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
|
|
2872
|
+
: `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
|
|
2873
|
+
`You may reply once without subscribing. To remain closed, call channel_decline ` +
|
|
2874
|
+
`with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
|
|
2875
|
+
`optionally set acknowledge to a surface value such as 👀.`,
|
|
2876
|
+
});
|
|
2877
|
+
metadata.channelInvitation = true;
|
|
2878
|
+
metadata.channelOpen = false;
|
|
2879
|
+
metadata.channelId = triggerChannel.channelId;
|
|
2880
|
+
metadata.invitationMessageId = messageId;
|
|
2685
2881
|
}
|
|
2882
|
+
const id = this.addMessage('user', content, metadata);
|
|
2883
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
2686
2884
|
if (event.triggerInference) {
|
|
2687
2885
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
2688
2886
|
const targetAgents = event.targetAgents
|
|
@@ -2825,6 +3023,7 @@ export class AgentFramework {
|
|
|
2825
3023
|
else {
|
|
2826
3024
|
this.activeTriggerChannels.delete(agent.name);
|
|
2827
3025
|
}
|
|
3026
|
+
this.touchEphemeralRun(agent.name, true);
|
|
2828
3027
|
this.emitTrace({ type: 'inference:started', agentName: agent.name });
|
|
2829
3028
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
2830
3029
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -2880,17 +3079,23 @@ export class AgentFramework {
|
|
|
2880
3079
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
2881
3080
|
}
|
|
2882
3081
|
else {
|
|
3082
|
+
this.settleAgent(agent.name, {
|
|
3083
|
+
stopReason: 'exhausted',
|
|
3084
|
+
speech: '',
|
|
3085
|
+
error: err.message,
|
|
3086
|
+
});
|
|
2883
3087
|
this.emitTrace({
|
|
2884
3088
|
type: 'inference:exhausted',
|
|
2885
3089
|
agentName: agent.name,
|
|
2886
3090
|
error: err.message,
|
|
2887
|
-
// Drives the poison-history breaker
|
|
2888
|
-
//
|
|
2889
|
-
//
|
|
2890
|
-
//
|
|
2891
|
-
//
|
|
2892
|
-
|
|
2893
|
-
|
|
3091
|
+
// Drives the poison-history breaker (`invalid_request`: a 400-class
|
|
3092
|
+
// rejection of the history itself — retrying the same context can
|
|
3093
|
+
// never succeed; auth/abort/context_length are also non-retryable
|
|
3094
|
+
// but must NOT cost history) and the OverBudget drain breaker
|
|
3095
|
+
// (`over_budget`: compile refused to fit the hard budget — this is
|
|
3096
|
+
// the site that sees it, since compile runs before the stream
|
|
3097
|
+
// exists). `retryable` is kept for observability.
|
|
3098
|
+
...this.classifyInferenceError(err),
|
|
2894
3099
|
});
|
|
2895
3100
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
2896
3101
|
if (action.emit) {
|
|
@@ -2904,15 +3109,69 @@ export class AgentFramework {
|
|
|
2904
3109
|
const requestId = `${agent.name}-${startTime}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2905
3110
|
const myStreamId = agent.streamId;
|
|
2906
3111
|
let hadToolCalls = false;
|
|
3112
|
+
// ---- Present-while-acting turn state ---------------------------------
|
|
3113
|
+
// Output locus for THIS turn: resolved lazily at the first routed prose
|
|
3114
|
+
// segment and pinned for the rest of the turn (mid-turn rounds AND the
|
|
3115
|
+
// trailing prose at 'complete'), so one turn's voice stays in one channel
|
|
3116
|
+
// even when a queued inbound from another channel overwrites the
|
|
3117
|
+
// per-agent triggering channel between segments (PR #32). The pin lives
|
|
3118
|
+
// in `turnLocusPins` so it survives a context-budget stream restart. A
|
|
3119
|
+
// null resolution is NOT pinned — a turn whose locus is unresolvable
|
|
3120
|
+
// early (e.g. heartbeat-triggered) may still resolve a channel by the
|
|
3121
|
+
// time later prose routes.
|
|
3122
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
3123
|
+
this.turnLocusPins.delete(agent.name);
|
|
3124
|
+
}
|
|
3125
|
+
const resolveTurnLocus = () => {
|
|
3126
|
+
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
3127
|
+
if (locus === null && this.channelRegistry) {
|
|
3128
|
+
locus = this.channelRegistry.resolveLocus(agent.name);
|
|
3129
|
+
if (locus !== null)
|
|
3130
|
+
this.turnLocusPins.set(agent.name, locus);
|
|
3131
|
+
}
|
|
3132
|
+
return locus;
|
|
3133
|
+
};
|
|
3134
|
+
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
3135
|
+
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
3136
|
+
// here would stall consumption of the next round's events by
|
|
3137
|
+
// segments × RTT on every prose-bearing round. The 'complete' case
|
|
3138
|
+
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
3139
|
+
// is preserved end-to-end. Each link catches its own error: one failed
|
|
3140
|
+
// post must not silence the rest of the turn.
|
|
3141
|
+
let turnSpeechChain = Promise.resolve();
|
|
3142
|
+
const enqueueSpeech = (text, locus) => {
|
|
3143
|
+
turnSpeechChain = turnSpeechChain
|
|
3144
|
+
.then(async () => {
|
|
3145
|
+
await this.channelRegistry.routeSpeech(agent.name, text, locus);
|
|
3146
|
+
})
|
|
3147
|
+
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
3148
|
+
};
|
|
3149
|
+
// Sticky silencing: once any round uses a SILENCING_TOOLS member,
|
|
3150
|
+
// prose from that round ONWARD is suppressed — skip_reply means "stay
|
|
3151
|
+
// silent", an explicit send means "I deliver my own words". Earlier
|
|
3152
|
+
// rounds' prose was already delivered live and cannot be retracted.
|
|
3153
|
+
let turnSilenced = false;
|
|
3154
|
+
// Live routing is only trusted when the membrane provides verbatim
|
|
3155
|
+
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
3156
|
+
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
3157
|
+
// all earlier rounds' prose and raw tool/thinking XML) — live-routing it
|
|
3158
|
+
// would repost the transcript and leak thinking every round. When no
|
|
3159
|
+
// round was live-routed, the 'complete' case falls back to the
|
|
3160
|
+
// historical whole-turn routing so fallback-mode prose is still
|
|
3161
|
+
// delivered exactly once, at turn end.
|
|
3162
|
+
let liveProseRouting = false;
|
|
2907
3163
|
// Typing indicator: show "<agent> is typing…" in the channel she's
|
|
2908
3164
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
2909
3165
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
2910
3166
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
2911
|
-
const typingChannel = this.channelRegistry
|
|
3167
|
+
const typingChannel = this.channelRegistry
|
|
3168
|
+
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
3169
|
+
: null;
|
|
2912
3170
|
if (typingChannel)
|
|
2913
3171
|
this.channelRegistry.startTyping(typingChannel);
|
|
2914
3172
|
try {
|
|
2915
3173
|
for await (const event of stream) {
|
|
3174
|
+
this.touchEphemeralRun(agent.name, true);
|
|
2916
3175
|
switch (event.type) {
|
|
2917
3176
|
case 'tokens':
|
|
2918
3177
|
this.emitTrace({
|
|
@@ -2936,6 +3195,7 @@ export class AgentFramework {
|
|
|
2936
3195
|
}
|
|
2937
3196
|
case 'tool-calls': {
|
|
2938
3197
|
hadToolCalls = true;
|
|
3198
|
+
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
2939
3199
|
this.emitTrace({
|
|
2940
3200
|
type: 'inference:tool_calls_yielded',
|
|
2941
3201
|
agentName: agent.name,
|
|
@@ -2972,6 +3232,39 @@ export class AgentFramework {
|
|
|
2972
3232
|
for (const call of event.calls) {
|
|
2973
3233
|
this.dispatchToolCall(agent.name, call);
|
|
2974
3234
|
}
|
|
3235
|
+
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
3236
|
+
// instead of batching every segment to the end of the turn. Long
|
|
3237
|
+
// tool-using turns (robot control, live sessions) otherwise dump
|
|
3238
|
+
// the whole narration in one blob when the turn finally settles —
|
|
3239
|
+
// and, worse, make "talking" and "acting" feel mutually exclusive
|
|
3240
|
+
// to the agent. Mid-turn prose is speech (delivered live, in
|
|
3241
|
+
// order, via the turn's speech chain — never awaited here); the
|
|
3242
|
+
// `think` tool remains the private channel. Silencing is STICKY
|
|
3243
|
+
// from the round it occurs (see SILENCING_TOOLS). Only rounds
|
|
3244
|
+
// with verbatim roundContent are live-routed (see liveProseRouting
|
|
3245
|
+
// note above — the fallback preamble is cumulative in XML mode).
|
|
3246
|
+
if (this.channelRegistry) {
|
|
3247
|
+
const roundToolNames = event.calls.map((c) => c.name);
|
|
3248
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
3249
|
+
turnSilenced = true;
|
|
3250
|
+
}
|
|
3251
|
+
if (roundContent && roundContent.length > 0) {
|
|
3252
|
+
liveProseRouting = true;
|
|
3253
|
+
const roundSegments = splitProseSegments(assistantBlocks);
|
|
3254
|
+
if (roundSegments.length > 0) {
|
|
3255
|
+
if (turnSilenced) {
|
|
3256
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`);
|
|
3257
|
+
}
|
|
3258
|
+
else {
|
|
3259
|
+
const locus = resolveTurnLocus();
|
|
3260
|
+
console.error(`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`);
|
|
3261
|
+
for (const seg of roundSegments) {
|
|
3262
|
+
enqueueSpeech(seg, locus);
|
|
3263
|
+
}
|
|
3264
|
+
}
|
|
3265
|
+
}
|
|
3266
|
+
}
|
|
3267
|
+
}
|
|
2975
3268
|
// Stream's async iterator blocks on next() until provideToolResults() is called
|
|
2976
3269
|
break;
|
|
2977
3270
|
}
|
|
@@ -3021,14 +3314,16 @@ export class AgentFramework {
|
|
|
3021
3314
|
// thinking blocks from their tool_use turn (the API requires
|
|
3022
3315
|
// thinking to precede tool_use in the same assistant turn).
|
|
3023
3316
|
const lastToolIdx = response.content.reduce((last, b, i) => b.type === 'tool_use' || b.type === 'tool_result' ? i : last, -1);
|
|
3317
|
+
const terminalContent = lastToolIdx >= 0
|
|
3318
|
+
? response.content.slice(lastToolIdx + 1)
|
|
3319
|
+
: response.content;
|
|
3024
3320
|
if (lastToolIdx >= 0) {
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
agent.addAssistantResponse(trailingContent);
|
|
3321
|
+
if (terminalContent.length > 0) {
|
|
3322
|
+
agent.addAssistantResponse(terminalContent);
|
|
3028
3323
|
}
|
|
3029
3324
|
}
|
|
3030
3325
|
else {
|
|
3031
|
-
agent.addAssistantResponse(
|
|
3326
|
+
agent.addAssistantResponse(terminalContent);
|
|
3032
3327
|
}
|
|
3033
3328
|
// Run afterInference hooks (no-op if no MCPL servers)
|
|
3034
3329
|
if (this.hookOrchestrator) {
|
|
@@ -3063,10 +3358,13 @@ export class AgentFramework {
|
|
|
3063
3358
|
console.error('afterInference hook error:', error);
|
|
3064
3359
|
}
|
|
3065
3360
|
}
|
|
3066
|
-
// Separate speech from thoughts
|
|
3067
|
-
//
|
|
3068
|
-
//
|
|
3069
|
-
//
|
|
3361
|
+
// Separate speech from thoughts — for MODULE dispatch only
|
|
3362
|
+
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
3363
|
+
// decide what reaches the channel: mid-turn prose is routed live
|
|
3364
|
+
// per round (see the 'tool-calls' case) and trailing prose is
|
|
3365
|
+
// routed after this block. On a tool-using turn the module-level
|
|
3366
|
+
// convention remains "text is thoughts"; on a text-only turn all
|
|
3367
|
+
// text is speech.
|
|
3070
3368
|
const isTextBlock = (block) => block.type === 'text';
|
|
3071
3369
|
const allText = response.content.filter(isTextBlock);
|
|
3072
3370
|
const speechContent = hadToolCalls ? [] : allText;
|
|
@@ -3080,17 +3378,20 @@ export class AgentFramework {
|
|
|
3080
3378
|
cacheRead: du.cacheReadTokens,
|
|
3081
3379
|
}
|
|
3082
3380
|
: undefined;
|
|
3083
|
-
// Reset agent state
|
|
3084
|
-
// synchronous listeners
|
|
3085
|
-
//
|
|
3086
|
-
//
|
|
3087
|
-
//
|
|
3088
|
-
// trace boundary, failed its idle gate, and the promise hung
|
|
3089
|
-
// forever — every "zombie subagent" in production tracked back
|
|
3090
|
-
// here. Speech dispatch happens after but doesn't depend on the
|
|
3091
|
-
// status field.
|
|
3381
|
+
// Reset agent state before emitting inference:completed. Traces are
|
|
3382
|
+
// observability-only, but external synchronous listeners should
|
|
3383
|
+
// still see the terminal state at the terminal trace boundary.
|
|
3384
|
+
// Speech dispatch happens after but doesn't depend on the status
|
|
3385
|
+
// field.
|
|
3092
3386
|
agent.reset();
|
|
3093
3387
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3388
|
+
this.settleAgent(agent.name, {
|
|
3389
|
+
stopReason: 'completed',
|
|
3390
|
+
speech: terminalContent
|
|
3391
|
+
.filter((block) => block.type === 'text')
|
|
3392
|
+
.map((block) => block.text)
|
|
3393
|
+
.join('\n'),
|
|
3394
|
+
});
|
|
3094
3395
|
this.emitTrace({
|
|
3095
3396
|
type: 'inference:completed',
|
|
3096
3397
|
agentName: agent.name,
|
|
@@ -3235,65 +3536,48 @@ export class AgentFramework {
|
|
|
3235
3536
|
}
|
|
3236
3537
|
}
|
|
3237
3538
|
else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
3238
|
-
// Tool-call turn that also produced prose.
|
|
3239
|
-
//
|
|
3240
|
-
//
|
|
3241
|
-
//
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
// turn
|
|
3245
|
-
//
|
|
3246
|
-
// reaches the channel.
|
|
3247
|
-
//
|
|
3248
|
-
//
|
|
3249
|
-
//
|
|
3250
|
-
//
|
|
3251
|
-
//
|
|
3252
|
-
// *reply_message / *send_dm) — already sent the message, so
|
|
3253
|
-
// routing it again would double-post.
|
|
3254
|
-
// `think` is deliberately NOT here: it is silent *reasoning*, but
|
|
3255
|
-
// prose written after a think is the agent's actual reply and must
|
|
3256
|
-
// be delivered. (A think-only turn has no trailing prose and stays
|
|
3257
|
-
// silent via the `!t` check below.)
|
|
3258
|
-
const SILENCING = new Set([
|
|
3259
|
-
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
3260
|
-
]);
|
|
3261
|
-
const bare = (n) => (n.includes('--') ? n.split('--').pop() : n);
|
|
3539
|
+
// Tool-call turn that also produced prose. When live routing was
|
|
3540
|
+
// active (native roundContent), each mid-turn round's prose was
|
|
3541
|
+
// already delivered when that round yielded — only the TRAILING
|
|
3542
|
+
// prose (blocks after the last tool round) remains here. On the
|
|
3543
|
+
// fallback path (XML tool mode / older membrane: no roundContent,
|
|
3544
|
+
// so nothing was live-routed) the historical behavior applies:
|
|
3545
|
+
// route the whole turn's parsed segments, once, now. The global
|
|
3546
|
+
// speech/thoughts split is left untouched (module/TUI rendering
|
|
3547
|
+
// unaffected) — this only governs what reaches the channel.
|
|
3548
|
+
//
|
|
3549
|
+
// Silencing here is the sticky flag ∪ turn-wide scan: the scan
|
|
3550
|
+
// (over the whole turn's tool_use blocks) covers the fallback
|
|
3551
|
+
// path and the "send_message then a 'sent it.' postscript"
|
|
3552
|
+
// double-post guard; the sticky flag is its live-rounds twin.
|
|
3262
3553
|
const toolNames = response.content
|
|
3263
3554
|
.filter((b) => b.type === 'tool_use')
|
|
3264
3555
|
.map((b) => b.name)
|
|
3265
3556
|
.filter((n) => typeof n === 'string');
|
|
3266
|
-
const silenced = toolNames.some((n) =>
|
|
3267
|
-
|
|
3268
|
-
//
|
|
3269
|
-
//
|
|
3270
|
-
//
|
|
3271
|
-
|
|
3272
|
-
// reconstructs the emission order. Interleaved prose
|
|
3273
|
-
// ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
|
|
3274
|
-
// separate messages IN ORDER instead of being collapsed into one
|
|
3275
|
-
// trailing post (item 4). The silencing rule stays turn-wide: an
|
|
3276
|
-
// explicit send / skip suppresses ALL auto-routed prose this turn,
|
|
3277
|
-
// preserving the double-post guard.
|
|
3278
|
-
const segments = splitProseSegments(response.content);
|
|
3557
|
+
const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
3558
|
+
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
3559
|
+
// Preserve in-channel ordering: everything enqueued live must
|
|
3560
|
+
// land before the trailing prose. Awaited even when silenced —
|
|
3561
|
+
// the chain may still be flushing earlier rounds' posts.
|
|
3562
|
+
await turnSpeechChain;
|
|
3279
3563
|
if (silenced || segments.length === 0) {
|
|
3280
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
3281
|
-
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`);
|
|
3564
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
3565
|
+
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`);
|
|
3282
3566
|
}
|
|
3283
3567
|
else {
|
|
3284
|
-
//
|
|
3285
|
-
//
|
|
3286
|
-
//
|
|
3287
|
-
//
|
|
3288
|
-
//
|
|
3289
|
-
//
|
|
3290
|
-
//
|
|
3291
|
-
const
|
|
3292
|
-
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s)
|
|
3568
|
+
// Reuse the locus pinned at the turn's first live-routed
|
|
3569
|
+
// segment (or resolve it now for a turn whose only prose is
|
|
3570
|
+
// trailing). This dispatch runs AFTER the agent is idle (see
|
|
3571
|
+
// PR #32 note below), so a queued inbound from another channel
|
|
3572
|
+
// could otherwise overwrite the per-agent triggering channel
|
|
3573
|
+
// between segments — and a turn that narrated live into one
|
|
3574
|
+
// channel must not land its postscript in another.
|
|
3575
|
+
const locus = resolveTurnLocus();
|
|
3576
|
+
console.error(`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`);
|
|
3293
3577
|
// Deliver sequentially (await each) so the segments land in order.
|
|
3294
3578
|
for (const seg of segments) {
|
|
3295
3579
|
try {
|
|
3296
|
-
await this.channelRegistry.routeSpeech(agent.name, seg,
|
|
3580
|
+
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
3297
3581
|
}
|
|
3298
3582
|
catch (err) {
|
|
3299
3583
|
console.error('speech routing failed:', err);
|
|
@@ -3301,9 +3585,8 @@ export class AgentFramework {
|
|
|
3301
3585
|
}
|
|
3302
3586
|
}
|
|
3303
3587
|
}
|
|
3304
|
-
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
3305
|
-
// dispatchSpeech
|
|
3306
|
-
// must see status === 'idle'). Locus routing is speech dispatch and
|
|
3588
|
+
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
3589
|
+
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
3307
3590
|
// doesn't depend on the status field, so it correctly runs after.
|
|
3308
3591
|
break;
|
|
3309
3592
|
}
|
|
@@ -3335,16 +3618,20 @@ export class AgentFramework {
|
|
|
3335
3618
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
3336
3619
|
}
|
|
3337
3620
|
else {
|
|
3621
|
+
this.settleAgent(agent.name, {
|
|
3622
|
+
stopReason: 'exhausted',
|
|
3623
|
+
speech: '',
|
|
3624
|
+
error: err.message,
|
|
3625
|
+
});
|
|
3338
3626
|
this.emitTrace({
|
|
3339
3627
|
type: 'inference:exhausted',
|
|
3340
3628
|
agentName: agent.name,
|
|
3341
3629
|
error: err.message,
|
|
3342
3630
|
// Drives the poison-history breaker. `retryable` is kept for
|
|
3343
|
-
// observability, but the
|
|
3631
|
+
// observability, but the breakers gate on `errorType` — only an
|
|
3344
3632
|
// `invalid_request` (a 400-class rejection of the history
|
|
3345
3633
|
// itself) means retrying the same context can never succeed.
|
|
3346
|
-
|
|
3347
|
-
errorType: err instanceof MembraneError ? err.type : undefined,
|
|
3634
|
+
...this.classifyInferenceError(err),
|
|
3348
3635
|
});
|
|
3349
3636
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3350
3637
|
if (action.emit) {
|
|
@@ -3354,12 +3641,28 @@ export class AgentFramework {
|
|
|
3354
3641
|
break;
|
|
3355
3642
|
}
|
|
3356
3643
|
case 'aborted': {
|
|
3644
|
+
// Framework-initiated, non-terminal cancels (endTurn tool result,
|
|
3645
|
+
// context-budget restart) also surface here as `aborted` — but
|
|
3646
|
+
// the turn either already settled (endTurn) or a replacement
|
|
3647
|
+
// stream is queued (budget restart). Neither is a failure: no
|
|
3648
|
+
// reset, no settle, no spurious inference:exhausted (which would
|
|
3649
|
+
// reject an ephemeral's promise mid-run and bump the failure
|
|
3650
|
+
// streak). Gate release + stream teardown happen in `finally`.
|
|
3651
|
+
if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
|
|
3652
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
3653
|
+
return;
|
|
3654
|
+
}
|
|
3357
3655
|
const reason = event.reason ?? 'unknown';
|
|
3358
3656
|
// Only reset if this is still the active stream (a budget restart
|
|
3359
3657
|
// may have already started a new stream, bumping streamId)
|
|
3360
3658
|
if (agent.streamId === myStreamId) {
|
|
3361
3659
|
const durationMs = Date.now() - startTime;
|
|
3362
3660
|
agent.reset();
|
|
3661
|
+
this.settleAgent(agent.name, {
|
|
3662
|
+
stopReason: 'exhausted',
|
|
3663
|
+
speech: '',
|
|
3664
|
+
error: `Stream aborted: ${reason}`,
|
|
3665
|
+
});
|
|
3363
3666
|
this.emitTrace({
|
|
3364
3667
|
type: 'inference:exhausted',
|
|
3365
3668
|
agentName: agent.name,
|
|
@@ -3429,10 +3732,16 @@ export class AgentFramework {
|
|
|
3429
3732
|
error: err.message,
|
|
3430
3733
|
stack: err.stack,
|
|
3431
3734
|
});
|
|
3735
|
+
this.settleAgent(agent.name, {
|
|
3736
|
+
stopReason: 'exhausted',
|
|
3737
|
+
speech: '',
|
|
3738
|
+
error: err.message,
|
|
3739
|
+
});
|
|
3432
3740
|
this.emitTrace({
|
|
3433
3741
|
type: 'inference:exhausted',
|
|
3434
3742
|
agentName: agent.name,
|
|
3435
3743
|
error: err.message,
|
|
3744
|
+
...this.classifyInferenceError(err),
|
|
3436
3745
|
});
|
|
3437
3746
|
// Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
|
|
3438
3747
|
// threw) were previously only visible via in-memory trace listeners.
|
|
@@ -3462,6 +3771,7 @@ export class AgentFramework {
|
|
|
3462
3771
|
// Stop the typing indicator on every exit path (complete, error,
|
|
3463
3772
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
3464
3773
|
this.channelRegistry?.stopTyping();
|
|
3774
|
+
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
3465
3775
|
this.activeStreams.delete(agent.name);
|
|
3466
3776
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
3467
3777
|
// A conversation fork whose TTL closure turn just finished is done for
|
|
@@ -3665,6 +3975,11 @@ export class AgentFramework {
|
|
|
3665
3975
|
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
3666
3976
|
return;
|
|
3667
3977
|
}
|
|
3978
|
+
// Route the agent's typed, allowlisted hot-settings surface.
|
|
3979
|
+
if (enrichedCall.name === 'agent_settings') {
|
|
3980
|
+
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
3981
|
+
return;
|
|
3982
|
+
}
|
|
3668
3983
|
// Route gate_status tool
|
|
3669
3984
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
3670
3985
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -3765,12 +4080,19 @@ export class AgentFramework {
|
|
|
3765
4080
|
if (!agent) {
|
|
3766
4081
|
throw new Error('No agents configured');
|
|
3767
4082
|
}
|
|
3768
|
-
//
|
|
3769
|
-
//
|
|
4083
|
+
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
4084
|
+
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
4085
|
+
// OR while the target agent has a live stream at all. A message stored
|
|
4086
|
+
// mid-stream lands BEFORE the turn's assistant blocks in the window even
|
|
4087
|
+
// though the live conversation never saw it — so the next compile
|
|
4088
|
+
// diverges from the live prefix (prompt-cache bust) and the message
|
|
4089
|
+
// misses mid-turn injection. Deferred messages flush at the agent's next
|
|
4090
|
+
// tool boundary (where they are ALSO injected into the live stream —
|
|
4091
|
+
// hear-while-acting) or in driveStream's finally when the turn ends.
|
|
3770
4092
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
3771
|
-
if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
|
|
4093
|
+
if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
|
|
3772
4094
|
this.deferredMessages.push({ participant, content, metadata });
|
|
3773
|
-
return ''; // Deferred —
|
|
4095
|
+
return ''; // Deferred — flushed at the next boundary
|
|
3774
4096
|
}
|
|
3775
4097
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
3776
4098
|
}
|
|
@@ -3979,6 +4301,27 @@ export class AgentFramework {
|
|
|
3979
4301
|
}
|
|
3980
4302
|
return streak;
|
|
3981
4303
|
}
|
|
4304
|
+
/**
|
|
4305
|
+
* Classify a terminal inference error for the `inference:exhausted` trace.
|
|
4306
|
+
* Single-sourced for every emit site: the downstream breakers (poison-history
|
|
4307
|
+
* quarantine, OverBudget drain kick) gate on `errorType`, so classification
|
|
4308
|
+
* drift between sites would silently disable a safety net.
|
|
4309
|
+
*
|
|
4310
|
+
* context-manager's OverBudgetError is recognized by `err.name`: CM does not
|
|
4311
|
+
* export the class from its package root, so a cross-package `instanceof` is
|
|
4312
|
+
* unavailable — but `name` is set in its constructor and survives the package
|
|
4313
|
+
* boundary. Deliberately NOT a message match: the message wording belongs to
|
|
4314
|
+
* CM and can be reworded without warning.
|
|
4315
|
+
*/
|
|
4316
|
+
classifyInferenceError(err) {
|
|
4317
|
+
if (err instanceof MembraneError) {
|
|
4318
|
+
return { retryable: err.retryable, errorType: err.type };
|
|
4319
|
+
}
|
|
4320
|
+
if (err.name === 'OverBudgetError') {
|
|
4321
|
+
return { errorType: 'over_budget' };
|
|
4322
|
+
}
|
|
4323
|
+
return {};
|
|
4324
|
+
}
|
|
3982
4325
|
noteInferenceExhausted(agentName, reason, retryable, errorType) {
|
|
3983
4326
|
const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
|
|
3984
4327
|
this.consecutiveInferenceFailures.set(agentName, streak);
|
|
@@ -4006,6 +4349,42 @@ export class AgentFramework {
|
|
|
4006
4349
|
console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
|
|
4007
4350
|
}
|
|
4008
4351
|
}
|
|
4352
|
+
// (2b) OverBudget deadlock breaker. When compile fails with
|
|
4353
|
+
// OverBudgetError, the normal compression drain never runs: it is driven
|
|
4354
|
+
// by successful activity, which the over-budget state prevents - a closed
|
|
4355
|
+
// loop with no internal exit. Field data (2026-07-10, resident agent):
|
|
4356
|
+
// 36 minutes hard-down, zero self-rescue; only an operator raising the
|
|
4357
|
+
// budget externally could break the loop. Break it here instead: kick the
|
|
4358
|
+
// strategy drain directly so folding/merging frees space for the next
|
|
4359
|
+
// compile. Bounded ticks, best-effort, never throws, one kick per agent
|
|
4360
|
+
// at a time (see overBudgetDrainInFlight).
|
|
4361
|
+
//
|
|
4362
|
+
// Gates on the classified errorType (see classifyInferenceError); the
|
|
4363
|
+
// message match is only a fallback for paths that lost the Error object
|
|
4364
|
+
// (e.g. a reason string that crossed a serialization boundary). It matches
|
|
4365
|
+
// CM's current OverBudgetError wording and MAY rot if CM rewords it — the
|
|
4366
|
+
// errorType gate is the one that's load-bearing.
|
|
4367
|
+
const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
|
|
4368
|
+
if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
|
|
4369
|
+
this.overBudgetDrainInFlight.add(agentName);
|
|
4370
|
+
void (async () => {
|
|
4371
|
+
let ticks = 0;
|
|
4372
|
+
try {
|
|
4373
|
+
const cm = agent.getContextManager();
|
|
4374
|
+
while (ticks < 8) {
|
|
4375
|
+
await cm.tick();
|
|
4376
|
+
ticks++;
|
|
4377
|
+
}
|
|
4378
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
|
|
4379
|
+
}
|
|
4380
|
+
catch (err) {
|
|
4381
|
+
console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks:`, err);
|
|
4382
|
+
}
|
|
4383
|
+
finally {
|
|
4384
|
+
this.overBudgetDrainInFlight.delete(agentName);
|
|
4385
|
+
}
|
|
4386
|
+
})();
|
|
4387
|
+
}
|
|
4009
4388
|
// (3) Hard-down escalation on repeated identical failure.
|
|
4010
4389
|
if (streak >= this.inferenceFailureEscalationThreshold) {
|
|
4011
4390
|
console.error(`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
|
|
@@ -4101,6 +4480,7 @@ export class AgentFramework {
|
|
|
4101
4480
|
this.checkpointManager = new CheckpointManager(this.store, (event) => this.emitTrace(event));
|
|
4102
4481
|
// Channel registry (Step 7)
|
|
4103
4482
|
this.channelRegistry = new ChannelRegistry(this.mcplServerRegistry, this.featureSetManager, (event) => this.pushEvent(event), (event) => this.emitTrace(event), {
|
|
4483
|
+
store: this.store,
|
|
4104
4484
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
4105
4485
|
const server = this.mcplServerRegistry.getServer(serverId);
|
|
4106
4486
|
if (server) {
|
|
@@ -4193,8 +4573,8 @@ export class AgentFramework {
|
|
|
4193
4573
|
this.mcplServerConfigs.set(config.id, config);
|
|
4194
4574
|
// Record per-server channel subscription policy before the server
|
|
4195
4575
|
// registers channels — handleRegister fires during the handshake.
|
|
4196
|
-
if (
|
|
4197
|
-
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription);
|
|
4576
|
+
if (this.channelRegistry) {
|
|
4577
|
+
this.channelRegistry.setSubscriptionPolicy(config.id, config.channelSubscription ?? 'manual');
|
|
4198
4578
|
}
|
|
4199
4579
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
4200
4580
|
// Wire event listeners then flush any events that arrived during the
|
|
@@ -4900,6 +5280,84 @@ export class AgentFramework {
|
|
|
4900
5280
|
endTurn: true,
|
|
4901
5281
|
});
|
|
4902
5282
|
}
|
|
5283
|
+
dispatchAgentSettingsToolCall(agentName, call) {
|
|
5284
|
+
this.emitTrace({
|
|
5285
|
+
type: 'tool:started',
|
|
5286
|
+
module: 'agent',
|
|
5287
|
+
tool: call.name,
|
|
5288
|
+
callId: call.id,
|
|
5289
|
+
input: call.input,
|
|
5290
|
+
});
|
|
5291
|
+
let result;
|
|
5292
|
+
try {
|
|
5293
|
+
const input = (call.input ?? {});
|
|
5294
|
+
switch (input.action) {
|
|
5295
|
+
case 'get':
|
|
5296
|
+
result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
|
|
5297
|
+
break;
|
|
5298
|
+
case 'cancel':
|
|
5299
|
+
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
5300
|
+
break;
|
|
5301
|
+
case 'update': {
|
|
5302
|
+
const patch = {};
|
|
5303
|
+
if (input.context_budget_tokens !== undefined) {
|
|
5304
|
+
patch.contextBudgetTokens = Number(input.context_budget_tokens);
|
|
5305
|
+
}
|
|
5306
|
+
if (input.tail_tokens !== undefined)
|
|
5307
|
+
patch.tailTokens = Number(input.tail_tokens);
|
|
5308
|
+
if (input.transition_pace_tokens !== undefined) {
|
|
5309
|
+
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
5310
|
+
}
|
|
5311
|
+
result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
|
|
5312
|
+
break;
|
|
5313
|
+
}
|
|
5314
|
+
case 'reset': {
|
|
5315
|
+
let keys;
|
|
5316
|
+
if (input.settings !== undefined) {
|
|
5317
|
+
if (!Array.isArray(input.settings))
|
|
5318
|
+
throw new Error('reset `settings` must be an array');
|
|
5319
|
+
const names = {
|
|
5320
|
+
context_budget_tokens: 'contextBudgetTokens',
|
|
5321
|
+
tail_tokens: 'tailTokens',
|
|
5322
|
+
transition_pace_tokens: 'transitionPaceTokens',
|
|
5323
|
+
};
|
|
5324
|
+
keys = input.settings.map((name) => {
|
|
5325
|
+
if (typeof name !== 'string' || !names[name]) {
|
|
5326
|
+
throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
5327
|
+
}
|
|
5328
|
+
return names[name];
|
|
5329
|
+
});
|
|
5330
|
+
}
|
|
5331
|
+
result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
|
|
5332
|
+
break;
|
|
5333
|
+
}
|
|
5334
|
+
default:
|
|
5335
|
+
throw new Error('agent_settings: action must be get, update, reset, or cancel');
|
|
5336
|
+
}
|
|
5337
|
+
}
|
|
5338
|
+
catch (error) {
|
|
5339
|
+
result = {
|
|
5340
|
+
success: false,
|
|
5341
|
+
error: error instanceof Error ? error.message : String(error),
|
|
5342
|
+
isError: true,
|
|
5343
|
+
};
|
|
5344
|
+
}
|
|
5345
|
+
this.emitTrace({
|
|
5346
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
5347
|
+
module: 'agent',
|
|
5348
|
+
tool: call.name,
|
|
5349
|
+
callId: call.id,
|
|
5350
|
+
durationMs: 0,
|
|
5351
|
+
...(result.isError ? { error: result.error } : {}),
|
|
5352
|
+
});
|
|
5353
|
+
this.pushEvent({
|
|
5354
|
+
type: 'tool-result',
|
|
5355
|
+
callId: call.id,
|
|
5356
|
+
agentName,
|
|
5357
|
+
moduleName: 'agent',
|
|
5358
|
+
result,
|
|
5359
|
+
});
|
|
5360
|
+
}
|
|
4903
5361
|
/** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
|
|
4904
5362
|
* server's declared tag ontology + gate.js status. */
|
|
4905
5363
|
buildEventTagsResult() {
|