@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/src/framework.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type {
|
|
|
32
32
|
ProcessEvent,
|
|
33
33
|
EventResponse,
|
|
34
34
|
ModuleProcessResponse,
|
|
35
|
+
AgentSettleResult,
|
|
35
36
|
ToolCall,
|
|
36
37
|
ToolCallEvent,
|
|
37
38
|
ToolResult,
|
|
@@ -43,6 +44,9 @@ import type {
|
|
|
43
44
|
ContextMaintenanceRun,
|
|
44
45
|
ContextMaintenanceAgentRun,
|
|
45
46
|
ContextMaintenanceSnapshot,
|
|
47
|
+
AgentRuntimeSettingsPatch,
|
|
48
|
+
AgentRuntimeSettingsOverrides,
|
|
49
|
+
AgentRuntimeSettingsSnapshot,
|
|
46
50
|
} from './types/index.js';
|
|
47
51
|
import { ProcessQueueImpl } from './queue.js';
|
|
48
52
|
import { Agent } from './agent.js';
|
|
@@ -58,6 +62,30 @@ import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
|
58
62
|
import { safeSlice } from './safe-slice.js';
|
|
59
63
|
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
60
64
|
import { splitProseSegments } from './prose-segments.js';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Tools whose presence suppresses auto-routing of the surrounding prose,
|
|
68
|
+
* for two distinct reasons:
|
|
69
|
+
* - `skip_reply` — the agent EXPLICITLY chose not to reply (the deliberate
|
|
70
|
+
* "stay silent" signal).
|
|
71
|
+
* - explicit delivery tools (channel_publish / *send_message /
|
|
72
|
+
* *reply_message / *send_dm) — already sent the message, so routing the
|
|
73
|
+
* prose again would double-post.
|
|
74
|
+
* `think` is deliberately NOT here: it is silent *reasoning*, but prose
|
|
75
|
+
* written around a think is the agent's actual voice and must be delivered.
|
|
76
|
+
*
|
|
77
|
+
* Scope: mid-turn rounds apply this PER ROUND (a round's prose is suppressed
|
|
78
|
+
* only by a silencing tool in that same round — live delivery can't be
|
|
79
|
+
* retracted by a later round's skip_reply); the turn's TRAILING prose keeps
|
|
80
|
+
* the historical turn-wide rule (any silencing tool anywhere in the turn
|
|
81
|
+
* suppresses it), preserving the "send_message then 'sent it.' postscript"
|
|
82
|
+
* double-post guard.
|
|
83
|
+
*/
|
|
84
|
+
const SILENCING_TOOLS = new Set([
|
|
85
|
+
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
86
|
+
]);
|
|
87
|
+
/** Strip the `server--` MCPL prefix from a tool name. */
|
|
88
|
+
const bareToolName = (n: string): string => n.split('--').pop()!;
|
|
61
89
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
62
90
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
63
91
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -229,6 +257,23 @@ interface RewindRecord {
|
|
|
229
257
|
discordRef?: { channelId: string; messageId: string };
|
|
230
258
|
}
|
|
231
259
|
|
|
260
|
+
interface Deferred<T> {
|
|
261
|
+
promise: Promise<T>;
|
|
262
|
+
resolve: (value: T) => void;
|
|
263
|
+
reject: (reason?: unknown) => void;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Per-run state for a framework-driven ephemeral agent: the settle signal
|
|
267
|
+
* the caller awaits plus the liveness/progress bookkeeping the watchdogs and
|
|
268
|
+
* the stream driver share. One map entry per run — created and torn down in
|
|
269
|
+
* runEphemeralToCompletion — so the pieces cannot desync. */
|
|
270
|
+
interface EphemeralRun {
|
|
271
|
+
settle: Deferred<AgentSettleResult>;
|
|
272
|
+
inferenceStarted: boolean;
|
|
273
|
+
lastActivity: number;
|
|
274
|
+
toolCallsCount: number;
|
|
275
|
+
}
|
|
276
|
+
|
|
232
277
|
/** Cap an API error message for inline use in a rewind marker: keep enough to
|
|
233
278
|
* identify the failure class without pasting a wall of provider JSON into the
|
|
234
279
|
* agent's context. */
|
|
@@ -274,7 +319,25 @@ export class AgentFramework {
|
|
|
274
319
|
private processLoggingPersist: boolean;
|
|
275
320
|
private processLoggingBroadcast: boolean;
|
|
276
321
|
private activeStreams: Map<string, Promise<void>> = new Map();
|
|
322
|
+
|
|
323
|
+
/** Per-agent output locus pinned for the CURRENT logical turn (see
|
|
324
|
+
* resolveTurnLocus in driveStream). Lives here — not in driveStream
|
|
325
|
+
* locals — so the pin survives a context-budget stream restart, which
|
|
326
|
+
* continues the same logical turn in a fresh driveStream. Cleared at the
|
|
327
|
+
* start of every non-restart turn. */
|
|
328
|
+
private turnLocusPins: Map<string, string> = new Map();
|
|
277
329
|
private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
|
|
330
|
+
/** Streams the FRAMEWORK cancelled for non-terminal reasons, keyed
|
|
331
|
+
* `${agentName}:${streamId}`: an endTurn tool result or a context-budget
|
|
332
|
+
* restart. The membrane still delivers an `aborted` event for these, and
|
|
333
|
+
* without the marker the abort handler would treat it as a terminal
|
|
334
|
+
* failure — settling ephemerals with a rejection and emitting a spurious
|
|
335
|
+
* `inference:exhausted` (which also pollutes the failure streak). Kept
|
|
336
|
+
* separate from ephemeralRuns deliberately: endTurn/budget cancels happen
|
|
337
|
+
* for resident agents too, and the key is per-stream, not per-agent. */
|
|
338
|
+
private frameworkCancelledStreams: Map<string, 'turn_ended' | 'budget_restart'> = new Map();
|
|
339
|
+
/** Active runEphemeralToCompletion runs, keyed by agent name. */
|
|
340
|
+
private ephemeralRuns: Map<string, EphemeralRun> = new Map();
|
|
278
341
|
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
279
342
|
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
280
343
|
private consecutiveInferenceFailures: Map<string, number> = new Map();
|
|
@@ -303,6 +366,13 @@ export class AgentFramework {
|
|
|
303
366
|
* Bounds the automatic quarantine loop the same way refusalRewinds bounds
|
|
304
367
|
* the refusal loop, so the breaker can never shed the whole history. */
|
|
305
368
|
private exhaustionRewinds: Map<string, number> = new Map();
|
|
369
|
+
/** Agents with an OverBudget drain kick currently in flight. The breaker in
|
|
370
|
+
* noteInferenceExhausted fires on EVERY matching failure and the scenario it
|
|
371
|
+
* exists for is "every activation fails, repeatedly" — without this guard,
|
|
372
|
+
* overlapping kicks race the strategy's own pendingCompression gate and the
|
|
373
|
+
* tick counts in the success log stop meaning anything. One kick per agent
|
|
374
|
+
* at a time; cleared when the kick settles (success or failure). */
|
|
375
|
+
private overBudgetDrainInFlight: Set<string> = new Set();
|
|
306
376
|
/** Per-agent current rewind episode: the single consolidated marker's id and
|
|
307
377
|
* how many turns have been shed so far. One marker per episode, updated in
|
|
308
378
|
* place (see updateRewindMarker); cleared when the episode ends. */
|
|
@@ -732,9 +802,16 @@ export class AgentFramework {
|
|
|
732
802
|
private startQueuedMaintenance(): void {
|
|
733
803
|
if (this.maintenancePass || !this.running) return;
|
|
734
804
|
const pass = this.runQueuedMaintenance().catch((error) => {
|
|
805
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
735
806
|
console.error(
|
|
736
807
|
'[context-maintenance] pass failed:',
|
|
737
|
-
|
|
808
|
+
reason,
|
|
809
|
+
);
|
|
810
|
+
this.opsAlert(
|
|
811
|
+
'context-maintenance-failed',
|
|
812
|
+
'framework',
|
|
813
|
+
reason,
|
|
814
|
+
{ data: { scope: 'pass' } },
|
|
738
815
|
);
|
|
739
816
|
});
|
|
740
817
|
this.maintenancePass = pass;
|
|
@@ -789,6 +866,18 @@ export class AgentFramework {
|
|
|
789
866
|
} catch (error) {
|
|
790
867
|
record.error = error instanceof Error ? error.message : String(error);
|
|
791
868
|
console.error(`[context-maintenance] agent=${agent.name} failed:`, record.error);
|
|
869
|
+
this.opsAlert(
|
|
870
|
+
'context-maintenance-failed',
|
|
871
|
+
agent.name,
|
|
872
|
+
record.error,
|
|
873
|
+
{
|
|
874
|
+
data: {
|
|
875
|
+
scope: 'agent',
|
|
876
|
+
...(record.pendingBefore ? { pending: record.pendingBefore } : {}),
|
|
877
|
+
...(record.progressBefore ? { progress: record.progressBefore } : {}),
|
|
878
|
+
},
|
|
879
|
+
},
|
|
880
|
+
);
|
|
792
881
|
} finally {
|
|
793
882
|
const pending = cm.getPendingWork()?.description;
|
|
794
883
|
record.finishedAt = Date.now();
|
|
@@ -934,10 +1023,49 @@ export class AgentFramework {
|
|
|
934
1023
|
AgentFramework.EVENT_TAGS_TOOL,
|
|
935
1024
|
]
|
|
936
1025
|
: [];
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1026
|
+
return [
|
|
1027
|
+
...moduleTools,
|
|
1028
|
+
...this.mcplTools,
|
|
1029
|
+
...channelTools,
|
|
1030
|
+
...gateTools,
|
|
1031
|
+
AgentFramework.AGENT_SETTINGS_TOOL,
|
|
1032
|
+
];
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
getAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsSnapshot {
|
|
1036
|
+
const agent = this.agents.get(agentName);
|
|
1037
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1038
|
+
return agent.getRuntimeSettings();
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
updateAgentRuntimeSettings(
|
|
1042
|
+
agentName: string,
|
|
1043
|
+
patch: AgentRuntimeSettingsPatch,
|
|
1044
|
+
): AgentRuntimeSettingsSnapshot {
|
|
1045
|
+
const agent = this.agents.get(agentName);
|
|
1046
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1047
|
+
const result = agent.updateRuntimeSettings(patch);
|
|
1048
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1049
|
+
return result;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
resetAgentRuntimeSettings(
|
|
1053
|
+
agentName: string,
|
|
1054
|
+
keys?: Array<keyof AgentRuntimeSettingsPatch>,
|
|
1055
|
+
): AgentRuntimeSettingsSnapshot {
|
|
1056
|
+
const agent = this.agents.get(agentName);
|
|
1057
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1058
|
+
const result = agent.resetRuntimeSettings(keys);
|
|
1059
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1060
|
+
return result;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
cancelAgentRuntimeSettingsTransition(agentName: string): AgentRuntimeSettingsSnapshot {
|
|
1064
|
+
const agent = this.agents.get(agentName);
|
|
1065
|
+
if (!agent) throw new Error(`Unknown agent: ${agentName}`);
|
|
1066
|
+
const result = agent.cancelRuntimeSettingsTransition();
|
|
1067
|
+
this.persistAgentRuntimeSettings(agentName, agent.getRuntimeSettingsOverrides());
|
|
1068
|
+
return result;
|
|
941
1069
|
}
|
|
942
1070
|
|
|
943
1071
|
/** Counts-only context-maintenance diagnostics for authenticated debug UIs. */
|
|
@@ -1139,6 +1267,33 @@ export class AgentFramework {
|
|
|
1139
1267
|
}
|
|
1140
1268
|
}
|
|
1141
1269
|
|
|
1270
|
+
private readAgentRuntimeSettings(agentName: string): AgentRuntimeSettingsOverrides | null {
|
|
1271
|
+
try {
|
|
1272
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID) as {
|
|
1273
|
+
agentRuntimeSettings?: Record<string, AgentRuntimeSettingsOverrides>;
|
|
1274
|
+
} | null;
|
|
1275
|
+
const stored = data?.agentRuntimeSettings?.[agentName];
|
|
1276
|
+
return stored ? { ...stored } : null;
|
|
1277
|
+
} catch {
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
private persistAgentRuntimeSettings(
|
|
1283
|
+
agentName: string,
|
|
1284
|
+
overrides: AgentRuntimeSettingsOverrides,
|
|
1285
|
+
): void {
|
|
1286
|
+
const data = this.store.getStateJson(FRAMEWORK_STATE_ID);
|
|
1287
|
+
const state = (data && typeof data === 'object' ? data : {}) as Record<string, unknown>;
|
|
1288
|
+
const all = {
|
|
1289
|
+
...((state.agentRuntimeSettings as Record<string, AgentRuntimeSettingsOverrides> | undefined) ?? {}),
|
|
1290
|
+
};
|
|
1291
|
+
if (Object.keys(overrides).length === 0) delete all[agentName];
|
|
1292
|
+
else all[agentName] = { ...overrides };
|
|
1293
|
+
state.agentRuntimeSettings = all;
|
|
1294
|
+
this.store.setStateJson(FRAMEWORK_STATE_ID, state);
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1142
1297
|
/**
|
|
1143
1298
|
* Create an ephemeral agent that is NOT registered in the main event loop.
|
|
1144
1299
|
*
|
|
@@ -1177,6 +1332,54 @@ export class AgentFramework {
|
|
|
1177
1332
|
return { agent, contextManager, cleanup };
|
|
1178
1333
|
}
|
|
1179
1334
|
|
|
1335
|
+
private createDeferred<T>(): Deferred<T> {
|
|
1336
|
+
let resolve!: (value: T) => void;
|
|
1337
|
+
let reject!: (reason?: unknown) => void;
|
|
1338
|
+
const promise = new Promise<T>((res, rej) => {
|
|
1339
|
+
resolve = res;
|
|
1340
|
+
reject = rej;
|
|
1341
|
+
});
|
|
1342
|
+
return { promise, resolve, reject };
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Liveness ping for an active ephemeral run (no-op otherwise). */
|
|
1346
|
+
private touchEphemeralRun(agentName: string, inferenceStarted = false): void {
|
|
1347
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1348
|
+
if (!run) return;
|
|
1349
|
+
if (inferenceStarted) {
|
|
1350
|
+
run.inferenceStarted = true;
|
|
1351
|
+
}
|
|
1352
|
+
run.lastActivity = Date.now();
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
private recordEphemeralToolCalls(agentName: string, count: number): void {
|
|
1356
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1357
|
+
if (!run) return;
|
|
1358
|
+
run.toolCallsCount += count;
|
|
1359
|
+
run.lastActivity = Date.now();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** Settle an active ephemeral run (no-op for resident agents). The tool-call
|
|
1363
|
+
* count comes from the run's own counter — the single source of truth,
|
|
1364
|
+
* which unlike a per-stream local survives budget restarts. */
|
|
1365
|
+
private settleAgent(
|
|
1366
|
+
agentName: string,
|
|
1367
|
+
outcome: { stopReason: AgentSettleResult['stopReason']; speech: string; error?: string },
|
|
1368
|
+
): void {
|
|
1369
|
+
const run = this.ephemeralRuns.get(agentName);
|
|
1370
|
+
if (!run) return;
|
|
1371
|
+
run.lastActivity = Date.now();
|
|
1372
|
+
if (outcome.stopReason === 'exhausted') {
|
|
1373
|
+
run.settle.reject(new Error(outcome.error ?? 'Unknown error'));
|
|
1374
|
+
return;
|
|
1375
|
+
}
|
|
1376
|
+
run.settle.resolve({
|
|
1377
|
+
stopReason: outcome.stopReason,
|
|
1378
|
+
speech: outcome.speech,
|
|
1379
|
+
toolCallsCount: run.toolCallsCount,
|
|
1380
|
+
});
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1180
1383
|
/**
|
|
1181
1384
|
* Run an ephemeral agent to completion through the framework's event loop.
|
|
1182
1385
|
*
|
|
@@ -1190,42 +1393,31 @@ export class AgentFramework {
|
|
|
1190
1393
|
async runEphemeralToCompletion(
|
|
1191
1394
|
agent: Agent,
|
|
1192
1395
|
contextManager: ContextManager,
|
|
1396
|
+
watchdogs?: { startupTimeoutMs?: number; idleTimeoutMs?: number; idlePollMs?: number },
|
|
1193
1397
|
): Promise<{ speech: string; toolCallsCount: number }> {
|
|
1194
1398
|
// Register temporarily so the event loop can drive it
|
|
1195
1399
|
this.agents.set(agent.name, agent);
|
|
1400
|
+
const run: EphemeralRun = {
|
|
1401
|
+
settle: this.createDeferred<AgentSettleResult>(),
|
|
1402
|
+
inferenceStarted: false,
|
|
1403
|
+
lastActivity: Date.now(),
|
|
1404
|
+
toolCallsCount: 0,
|
|
1405
|
+
};
|
|
1406
|
+
this.ephemeralRuns.set(agent.name, run);
|
|
1196
1407
|
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
const STARTUP_TIMEOUT_MS = 30_000;
|
|
1205
|
-
// After inference has started, give it 15 minutes of activity-bounded
|
|
1206
|
-
// life. Each addressed trace event refreshes the deadline; only
|
|
1207
|
-
// sustained silence trips it. Subagents that legitimately stream for
|
|
1208
|
-
// 10+ minutes (e.g. long fork investigations) get the extra slack but
|
|
1209
|
-
// stalled streams that emit `inference:completed` and then go quiet —
|
|
1210
|
-
// the exact zombie shape we saw in production — are caught here.
|
|
1211
|
-
const COMPLETION_IDLE_TIMEOUT_MS = 15 * 60_000;
|
|
1212
|
-
|
|
1213
|
-
const cleanup = () => {
|
|
1214
|
-
if (settled) return;
|
|
1215
|
-
settled = true;
|
|
1216
|
-
clearTimeout(startupWatchdog);
|
|
1217
|
-
clearInterval(completionWatchdog);
|
|
1218
|
-
this.offTrace(traceListener);
|
|
1219
|
-
this.agents.delete(agent.name);
|
|
1220
|
-
this.evictTurnCheckpoints(agent.name);
|
|
1221
|
-
};
|
|
1408
|
+
const STARTUP_TIMEOUT_MS = watchdogs?.startupTimeoutMs ?? 30_000;
|
|
1409
|
+
// After inference has started, give it 15 minutes of activity-bounded
|
|
1410
|
+
// life. The stream driver refreshes the deadline as it makes progress;
|
|
1411
|
+
// sustained silence trips it.
|
|
1412
|
+
const COMPLETION_IDLE_TIMEOUT_MS = watchdogs?.idleTimeoutMs ?? 15 * 60_000;
|
|
1413
|
+
const IDLE_POLL_MS = watchdogs?.idlePollMs ?? 30_000;
|
|
1222
1414
|
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1415
|
+
let startupWatchdog: ReturnType<typeof setTimeout> | null = null;
|
|
1416
|
+
let completionWatchdog: ReturnType<typeof setInterval> | null = null;
|
|
1417
|
+
|
|
1418
|
+
const startupTimeout = new Promise<never>((_, reject) => {
|
|
1419
|
+
startupWatchdog = setTimeout(() => {
|
|
1420
|
+
if (!run.inferenceStarted) {
|
|
1229
1421
|
reject(new Error(
|
|
1230
1422
|
`Ephemeral agent "${agent.name}" failed to start inference within ` +
|
|
1231
1423
|
`${STARTUP_TIMEOUT_MS}ms — zombie detected. The event loop may have ` +
|
|
@@ -1233,104 +1425,48 @@ export class AgentFramework {
|
|
|
1233
1425
|
));
|
|
1234
1426
|
}
|
|
1235
1427
|
}, STARTUP_TIMEOUT_MS);
|
|
1428
|
+
});
|
|
1236
1429
|
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
// and keeps its concurrency slot pinned.
|
|
1242
|
-
const completionWatchdog = setInterval(() => {
|
|
1243
|
-
if (settled || !inferenceStarted) return;
|
|
1244
|
-
const idle = Date.now() - lastActivity;
|
|
1430
|
+
const completionIdleTimeout = new Promise<never>((_, reject) => {
|
|
1431
|
+
completionWatchdog = setInterval(() => {
|
|
1432
|
+
if (!run.inferenceStarted) return;
|
|
1433
|
+
const idle = Date.now() - run.lastActivity;
|
|
1245
1434
|
if (idle > COMPLETION_IDLE_TIMEOUT_MS) {
|
|
1246
|
-
cleanup();
|
|
1247
1435
|
reject(new Error(
|
|
1248
|
-
`Ephemeral agent "${agent.name}" stalled: no
|
|
1436
|
+
`Ephemeral agent "${agent.name}" stalled: no stream-driver activity for ` +
|
|
1249
1437
|
`${Math.round(idle / 1000)}s after inference started (threshold ` +
|
|
1250
1438
|
`${Math.round(COMPLETION_IDLE_TIMEOUT_MS / 1000)}s). Stream likely ` +
|
|
1251
1439
|
`dropped or terminal event was lost.`
|
|
1252
1440
|
));
|
|
1253
1441
|
}
|
|
1254
|
-
},
|
|
1255
|
-
|
|
1256
|
-
const traceListener = (event: TraceEvent) => {
|
|
1257
|
-
if (settled) return;
|
|
1258
|
-
// Only track events for our ephemeral agent
|
|
1259
|
-
const agentName = 'agentName' in event ? (event as { agentName: string }).agentName : null;
|
|
1260
|
-
if (agentName !== agent.name) return;
|
|
1261
|
-
|
|
1262
|
-
// Any addressed event counts as liveness for the completion watchdog.
|
|
1263
|
-
lastActivity = Date.now();
|
|
1264
|
-
|
|
1265
|
-
switch (event.type) {
|
|
1266
|
-
case 'inference:started':
|
|
1267
|
-
inferenceStarted = true;
|
|
1268
|
-
break;
|
|
1269
|
-
case 'inference:tokens': {
|
|
1270
|
-
inferenceStarted = true;
|
|
1271
|
-
const content = (event as { content?: string }).content;
|
|
1272
|
-
if (content) speech += content;
|
|
1273
|
-
break;
|
|
1274
|
-
}
|
|
1275
|
-
case 'inference:tool_calls_yielded': {
|
|
1276
|
-
const calls = (event as { calls?: Array<unknown> }).calls;
|
|
1277
|
-
if (calls) toolCallsCount += calls.length;
|
|
1278
|
-
// Reset speech buffer — tool calls break the text
|
|
1279
|
-
speech = '';
|
|
1280
|
-
break;
|
|
1281
|
-
}
|
|
1282
|
-
case 'inference:stream_resumed':
|
|
1283
|
-
speech = '';
|
|
1284
|
-
break;
|
|
1285
|
-
case 'inference:completed': {
|
|
1286
|
-
// `inference:completed` is terminal — driveStream only emits it
|
|
1287
|
-
// from `case 'complete'`, which fires after the full tool
|
|
1288
|
-
// round-trip has finished and the model has stopped. Intermediate
|
|
1289
|
-
// tool-cycle transitions use `inference:stream_resumed`, not
|
|
1290
|
-
// `inference:completed`. So we resolve unconditionally here.
|
|
1291
|
-
//
|
|
1292
|
-
// Previously this branch gated on `agent.state.status === 'idle'`,
|
|
1293
|
-
// which was correct in intent but order-fragile: the framework
|
|
1294
|
-
// emitted `inference:completed` BEFORE calling `agent.reset()`,
|
|
1295
|
-
// so synchronous listeners observed status === 'streaming' at
|
|
1296
|
-
// the trace boundary. The gate failed silently and the promise
|
|
1297
|
-
// hung forever — every production zombie subagent originated
|
|
1298
|
-
// here. The reset+emit order has been fixed in driveStream's
|
|
1299
|
-
// `case 'complete'` handler; the gate is dropped here as
|
|
1300
|
-
// belt-and-suspenders.
|
|
1301
|
-
cleanup();
|
|
1302
|
-
resolve({ speech, toolCallsCount });
|
|
1303
|
-
break;
|
|
1304
|
-
}
|
|
1305
|
-
case 'inference:turn_ended': {
|
|
1306
|
-
// endTurn from a tool result — agent is done
|
|
1307
|
-
cleanup();
|
|
1308
|
-
resolve({ speech, toolCallsCount });
|
|
1309
|
-
break;
|
|
1310
|
-
}
|
|
1311
|
-
case 'inference:exhausted': {
|
|
1312
|
-
// Retries exhausted — reject only after the error policy gives up.
|
|
1313
|
-
// Earlier inference:failed events are left alone so the framework
|
|
1314
|
-
// retry path in driveStream/startAgentStream can restart the stream
|
|
1315
|
-
// while this listener stays alive.
|
|
1316
|
-
const error = (event as { error?: string }).error ?? 'Unknown error';
|
|
1317
|
-
cleanup();
|
|
1318
|
-
reject(new Error(error));
|
|
1319
|
-
break;
|
|
1320
|
-
}
|
|
1321
|
-
}
|
|
1322
|
-
};
|
|
1323
|
-
|
|
1324
|
-
this.onTrace(traceListener);
|
|
1442
|
+
}, IDLE_POLL_MS);
|
|
1443
|
+
});
|
|
1325
1444
|
|
|
1326
|
-
|
|
1445
|
+
try {
|
|
1446
|
+
// Trigger inference after the settle promise is registered.
|
|
1327
1447
|
this.pendingRequests.push({
|
|
1328
1448
|
agentName: agent.name,
|
|
1329
1449
|
reason: 'ephemeral',
|
|
1330
1450
|
source: 'subagent',
|
|
1331
1451
|
timestamp: Date.now(),
|
|
1332
1452
|
});
|
|
1333
|
-
|
|
1453
|
+
|
|
1454
|
+
const result = await Promise.race([
|
|
1455
|
+
run.settle.promise,
|
|
1456
|
+
startupTimeout,
|
|
1457
|
+
completionIdleTimeout,
|
|
1458
|
+
]);
|
|
1459
|
+
return { speech: result.speech, toolCallsCount: result.toolCallsCount };
|
|
1460
|
+
} finally {
|
|
1461
|
+
if (startupWatchdog) clearTimeout(startupWatchdog);
|
|
1462
|
+
if (completionWatchdog) clearInterval(completionWatchdog);
|
|
1463
|
+
this.ephemeralRuns.delete(agent.name);
|
|
1464
|
+
this.agents.delete(agent.name);
|
|
1465
|
+
// Spawn-and-dispose bookkeeping (main, d453165/fee96a7): without this,
|
|
1466
|
+
// ephemeral agents leave checkpoint-tree keys and diagnostics map
|
|
1467
|
+
// entries behind for the life of the store/session.
|
|
1468
|
+
this.evictTurnCheckpoints(agent.name);
|
|
1469
|
+
}
|
|
1334
1470
|
}
|
|
1335
1471
|
|
|
1336
1472
|
/**
|
|
@@ -1497,6 +1633,31 @@ export class AgentFramework {
|
|
|
1497
1633
|
inputSchema: { type: 'object' },
|
|
1498
1634
|
};
|
|
1499
1635
|
|
|
1636
|
+
private static readonly AGENT_SETTINGS_TOOL: import('./types/index.js').ToolDefinition = {
|
|
1637
|
+
name: 'agent_settings',
|
|
1638
|
+
description:
|
|
1639
|
+
'Read or change your hot runtime settings. This intentionally exposes only ' +
|
|
1640
|
+
'context budget, recent raw tail size, and transition pace; model, prompts, ' +
|
|
1641
|
+
'folding strategy, and other restart-bound configuration are not mutable here. ' +
|
|
1642
|
+
'Lower context budgets converge gradually under the transition pace before ' +
|
|
1643
|
+
'becoming the hard live limit; increases take effect immediately.',
|
|
1644
|
+
inputSchema: {
|
|
1645
|
+
type: 'object',
|
|
1646
|
+
properties: {
|
|
1647
|
+
action: { type: 'string', enum: ['get', 'update', 'reset', 'cancel'] },
|
|
1648
|
+
context_budget_tokens: { type: 'number', description: 'Total input context budget, including the reserved response allowance.' },
|
|
1649
|
+
tail_tokens: { type: 'number', description: 'Recent raw context retained verbatim.' },
|
|
1650
|
+
transition_pace_tokens: { type: 'number', description: 'Maximum ordinary KV re-read/perturbation per compile while converging.' },
|
|
1651
|
+
settings: {
|
|
1652
|
+
type: 'array',
|
|
1653
|
+
items: { type: 'string', enum: ['context_budget_tokens', 'tail_tokens', 'transition_pace_tokens'] },
|
|
1654
|
+
description: 'For reset: settings to restore to recipe values. Omit to reset all.',
|
|
1655
|
+
},
|
|
1656
|
+
},
|
|
1657
|
+
required: ['action'],
|
|
1658
|
+
},
|
|
1659
|
+
};
|
|
1660
|
+
|
|
1500
1661
|
/** Reserved chat:* core vocabulary (MCPL RFC-001 §4) — short descriptions so
|
|
1501
1662
|
* the agent can author rules without reading the spec. */
|
|
1502
1663
|
private static readonly CHAT_CORE_TAGS: Record<string, string> = {
|
|
@@ -2565,6 +2726,8 @@ export class AgentFramework {
|
|
|
2565
2726
|
});
|
|
2566
2727
|
|
|
2567
2728
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
2729
|
+
const restoredSettings = this.readAgentRuntimeSettings(config.name);
|
|
2730
|
+
if (restoredSettings) agent.restoreRuntimeSettings(restoredSettings);
|
|
2568
2731
|
this.agents.set(config.name, agent);
|
|
2569
2732
|
this.agentConfigs.set(config.name, config);
|
|
2570
2733
|
|
|
@@ -2619,6 +2782,9 @@ export class AgentFramework {
|
|
|
2619
2782
|
// messages between tool_use and tool_result, causing a 400 error.
|
|
2620
2783
|
if (event.type === 'tool-result') {
|
|
2621
2784
|
const agent = this.agents.get(event.agentName);
|
|
2785
|
+
if (agent) {
|
|
2786
|
+
this.touchEphemeralRun(event.agentName, true);
|
|
2787
|
+
}
|
|
2622
2788
|
if (!agent) {
|
|
2623
2789
|
console.warn(
|
|
2624
2790
|
`[framework] Dropping tool-result for unknown agent '${event.agentName}' (callId=${event.callId}). ` +
|
|
@@ -2669,16 +2835,51 @@ export class AgentFramework {
|
|
|
2669
2835
|
}));
|
|
2670
2836
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
2671
2837
|
|
|
2672
|
-
// Flush any messages that were deferred while
|
|
2673
|
-
// Route to the PRIMARY agent — deferred messages are
|
|
2674
|
-
// (
|
|
2838
|
+
// Flush any messages that were deferred while this turn was in
|
|
2839
|
+
// flight. Route to the PRIMARY agent — deferred messages are
|
|
2840
|
+
// framework-level (module messages, push events, subagent return
|
|
2841
|
+
// notifications) meant for the main conversation.
|
|
2842
|
+
//
|
|
2843
|
+
// Hear-while-acting: messages flushed into THIS agent's own window
|
|
2844
|
+
// are also collected for mid-turn injection — passed to the membrane
|
|
2845
|
+
// with the tool results below, so the NEXT inference round of the
|
|
2846
|
+
// live turn sees them instead of the agent staying deaf until the
|
|
2847
|
+
// turn ends. The window stores them here, right after the
|
|
2848
|
+
// tool_result message, which is exactly where the membrane appends
|
|
2849
|
+
// them in the live conversation — same participant, same content
|
|
2850
|
+
// blocks, same metadata, and compile() emits recent messages
|
|
2851
|
+
// verbatim, so the next turn's compiled prefix byte-matches the
|
|
2852
|
+
// live request (prompt-cache safe).
|
|
2853
|
+
//
|
|
2854
|
+
// The queue is drained ONLY at the target agent's own boundary:
|
|
2855
|
+
// draining at another agent's boundary would store the messages
|
|
2856
|
+
// mid-turn in the target's window without injecting them into the
|
|
2857
|
+
// target's live stream (deaf agent + window/live divergence). Left
|
|
2858
|
+
// queued, they flush at the target's own next boundary — with
|
|
2859
|
+
// injection — or in driveStream's finally when its turn ends.
|
|
2860
|
+
const midTurnInjections: Array<{ participant: string; content: ContentBlock[]; metadata?: MessageMetadata }> = [];
|
|
2675
2861
|
if (this.deferredMessages.length > 0) {
|
|
2676
|
-
const
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2862
|
+
const target = (this.primaryAgentName ? this.agents.get(this.primaryAgentName) : undefined) ?? agent;
|
|
2863
|
+
if (target === agent) {
|
|
2864
|
+
const deferred = this.deferredMessages.splice(0);
|
|
2865
|
+
for (const msg of deferred) {
|
|
2866
|
+
target.getContextManager().addMessage(msg.participant, msg.content, msg.metadata);
|
|
2867
|
+
// Injection guards: tool blocks would corrupt the tool-cycle
|
|
2868
|
+
// structure the membrane enforces, and a message named as the
|
|
2869
|
+
// agent itself would render as an ASSISTANT turn on the wire
|
|
2870
|
+
// (an unintended prefill the model would continue). Such
|
|
2871
|
+
// messages stay window-only — visible next turn.
|
|
2872
|
+
const hasToolBlocks = msg.content.some(
|
|
2873
|
+
(b) => b.type === 'tool_use' || b.type === 'tool_result'
|
|
2874
|
+
);
|
|
2875
|
+
if (!hasToolBlocks && msg.participant !== agent.name) {
|
|
2876
|
+
midTurnInjections.push({
|
|
2877
|
+
participant: msg.participant,
|
|
2878
|
+
content: msg.content,
|
|
2879
|
+
...(msg.metadata ? { metadata: msg.metadata } : {}),
|
|
2880
|
+
});
|
|
2881
|
+
}
|
|
2882
|
+
}
|
|
2682
2883
|
}
|
|
2683
2884
|
}
|
|
2684
2885
|
|
|
@@ -2693,12 +2894,28 @@ export class AgentFramework {
|
|
|
2693
2894
|
if (shouldEndTurn) {
|
|
2694
2895
|
// endTurn: messages already stored above, cancel stream, reset to idle.
|
|
2695
2896
|
if (currentState.stream) {
|
|
2696
|
-
agent.
|
|
2897
|
+
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'turn_ended');
|
|
2898
|
+
currentState.stream.cancel();
|
|
2697
2899
|
}
|
|
2698
2900
|
agent.reset();
|
|
2901
|
+
// The turn is over — release the gate here, not just in the abort
|
|
2902
|
+
// handler: the membrane's aborted event is asynchronous and the
|
|
2903
|
+
// agent must be wakeable the moment it goes idle. (Idempotent;
|
|
2904
|
+
// driveStream's finally is the backstop.)
|
|
2905
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
2906
|
+
this.settleAgent(agent.name, { stopReason: 'turn_ended', speech: '' });
|
|
2699
2907
|
this.emitTrace({ type: 'inference:turn_ended', agentName: agent.name });
|
|
2700
2908
|
} else if (overBudget) {
|
|
2701
|
-
// Context budget exceeded: break the stream, let compile() compress
|
|
2909
|
+
// Context budget exceeded: break the stream, let compile() compress.
|
|
2910
|
+
// Mark the cancel as framework-initiated BEFORE cancelling: the
|
|
2911
|
+
// membrane delivers `aborted` before the restart bumps streamId,
|
|
2912
|
+
// and without the marker the abort handler treats the restart as
|
|
2913
|
+
// a terminal failure — rejecting an ephemeral's promise mid-run
|
|
2914
|
+
// and emitting a spurious inference:exhausted (same race shape as
|
|
2915
|
+
// endTurn above).
|
|
2916
|
+
if (currentState.stream) {
|
|
2917
|
+
this.frameworkCancelledStreams.set(`${agent.name}:${agent.streamId}`, 'budget_restart');
|
|
2918
|
+
}
|
|
2702
2919
|
agent.cancelStream();
|
|
2703
2920
|
this.emitTrace({
|
|
2704
2921
|
type: 'inference:stream_restarted',
|
|
@@ -2714,13 +2931,23 @@ export class AgentFramework {
|
|
|
2714
2931
|
timestamp: Date.now(),
|
|
2715
2932
|
});
|
|
2716
2933
|
} else if (currentState.stream) {
|
|
2717
|
-
// Streaming path: convert results and resume the stream
|
|
2934
|
+
// Streaming path: convert results and resume the stream.
|
|
2935
|
+
// Mid-turn messages collected above ride along as injected user
|
|
2936
|
+
// messages (membrane ≥0.5.72) — appended after the tool_result
|
|
2937
|
+
// envelope so the next round of THIS turn hears them.
|
|
2718
2938
|
const membraneResults = currentState.toolResults.map(tc =>
|
|
2719
2939
|
this.toMembraneToolResult(tc.id, tc.result, maxChars)
|
|
2720
2940
|
);
|
|
2721
|
-
currentState.stream.provideToolResults(
|
|
2941
|
+
currentState.stream.provideToolResults(
|
|
2942
|
+
membraneResults,
|
|
2943
|
+
midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections } : undefined,
|
|
2944
|
+
);
|
|
2722
2945
|
agent.setStreaming(currentState.stream);
|
|
2723
|
-
this.emitTrace({
|
|
2946
|
+
this.emitTrace({
|
|
2947
|
+
type: 'inference:stream_resumed',
|
|
2948
|
+
agentName: agent.name,
|
|
2949
|
+
...(midTurnInjections.length > 0 ? { injectedMessages: midTurnInjections.length } : {}),
|
|
2950
|
+
});
|
|
2724
2951
|
} else {
|
|
2725
2952
|
// Non-streaming fallback: schedule re-inference
|
|
2726
2953
|
this.pendingRequests.push({
|
|
@@ -3174,6 +3401,15 @@ export class AgentFramework {
|
|
|
3174
3401
|
* Convert an MCPL push event to a context message.
|
|
3175
3402
|
*/
|
|
3176
3403
|
private handleMcplPushEvent(event: McplPushEvent): void {
|
|
3404
|
+
const triggerChannel = this.derivePushEventChannel(event.origin);
|
|
3405
|
+
if (triggerChannel && this.channelRegistry) {
|
|
3406
|
+
this.channelRegistry.ensureChannelRegistered(
|
|
3407
|
+
event.serverId,
|
|
3408
|
+
triggerChannel.channelId,
|
|
3409
|
+
triggerChannel.label,
|
|
3410
|
+
);
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3177
3413
|
const metadata: Record<string, unknown> = {
|
|
3178
3414
|
...event.origin,
|
|
3179
3415
|
serverId: event.serverId,
|
|
@@ -3182,23 +3418,39 @@ export class AgentFramework {
|
|
|
3182
3418
|
triggered: event.triggerInference ?? false,
|
|
3183
3419
|
};
|
|
3184
3420
|
|
|
3185
|
-
const
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
3199
|
-
|
|
3421
|
+
const content = [...event.content];
|
|
3422
|
+
const addressedWhileClosed = triggerChannel &&
|
|
3423
|
+
event.tags?.includes('chat:addressed') &&
|
|
3424
|
+
this.channelRegistry &&
|
|
3425
|
+
!this.channelRegistry.isChannelOpen(triggerChannel.channelId);
|
|
3426
|
+
if (addressedWhileClosed) {
|
|
3427
|
+
const descriptor = this.channelRegistry!.getDescriptor(triggerChannel.channelId);
|
|
3428
|
+
const messageId = typeof event.origin?.messageId === 'string'
|
|
3429
|
+
? event.origin.messageId
|
|
3430
|
+
: event.eventId;
|
|
3431
|
+
const maxBackscroll = descriptor?.capabilities?.history?.maxMessages ?? 0;
|
|
3432
|
+
content.push({
|
|
3433
|
+
type: 'text',
|
|
3434
|
+
text:
|
|
3435
|
+
`\n[Channel invitation] You were addressed in closed channel ` +
|
|
3436
|
+
`"${triggerChannel.label ?? triggerChannel.channelId}" (${triggerChannel.channelId}, server ${event.serverId}). ` +
|
|
3437
|
+
`You received this one message without subscribing. ` +
|
|
3438
|
+
(maxBackscroll > 0
|
|
3439
|
+
? `To subscribe, call channel_open with channelId, serverId "${event.serverId}", backscroll (0-${maxBackscroll}), and beforeMessageId "${messageId}". `
|
|
3440
|
+
: `To subscribe, call channel_open with this channelId and serverId "${event.serverId}". `) +
|
|
3441
|
+
`You may reply once without subscribing. To remain closed, call channel_decline ` +
|
|
3442
|
+
`with channelId, serverId "${event.serverId}", and messageId "${messageId}"; ` +
|
|
3443
|
+
`optionally set acknowledge to a surface value such as 👀.`,
|
|
3444
|
+
});
|
|
3445
|
+
metadata.channelInvitation = true;
|
|
3446
|
+
metadata.channelOpen = false;
|
|
3447
|
+
metadata.channelId = triggerChannel.channelId;
|
|
3448
|
+
metadata.invitationMessageId = messageId;
|
|
3200
3449
|
}
|
|
3201
3450
|
|
|
3451
|
+
const id = this.addMessage('user', content, metadata);
|
|
3452
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
3453
|
+
|
|
3202
3454
|
if (event.triggerInference) {
|
|
3203
3455
|
// Default broadcast excludes conversation forks (channel-driven).
|
|
3204
3456
|
const targetAgents = event.targetAgents
|
|
@@ -3364,6 +3616,7 @@ export class AgentFramework {
|
|
|
3364
3616
|
this.activeTriggerChannels.delete(agent.name);
|
|
3365
3617
|
}
|
|
3366
3618
|
|
|
3619
|
+
this.touchEphemeralRun(agent.name, true);
|
|
3367
3620
|
this.emitTrace({ type: 'inference:started', agentName: agent.name });
|
|
3368
3621
|
this.eventGate?.onInferenceStarted(agent.name);
|
|
3369
3622
|
this.lastInferenceAt.set(agent.name, { ...this.lastInferenceAt.get(agent.name), startedAt: Date.now() });
|
|
@@ -3428,17 +3681,23 @@ export class AgentFramework {
|
|
|
3428
3681
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
3429
3682
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
3430
3683
|
} else {
|
|
3684
|
+
this.settleAgent(agent.name, {
|
|
3685
|
+
stopReason: 'exhausted',
|
|
3686
|
+
speech: '',
|
|
3687
|
+
error: err.message,
|
|
3688
|
+
});
|
|
3431
3689
|
this.emitTrace({
|
|
3432
3690
|
type: 'inference:exhausted',
|
|
3433
3691
|
agentName: agent.name,
|
|
3434
3692
|
error: err.message,
|
|
3435
|
-
// Drives the poison-history breaker
|
|
3436
|
-
//
|
|
3437
|
-
//
|
|
3438
|
-
//
|
|
3439
|
-
//
|
|
3440
|
-
|
|
3441
|
-
|
|
3693
|
+
// Drives the poison-history breaker (`invalid_request`: a 400-class
|
|
3694
|
+
// rejection of the history itself — retrying the same context can
|
|
3695
|
+
// never succeed; auth/abort/context_length are also non-retryable
|
|
3696
|
+
// but must NOT cost history) and the OverBudget drain breaker
|
|
3697
|
+
// (`over_budget`: compile refused to fit the hard budget — this is
|
|
3698
|
+
// the site that sees it, since compile runs before the stream
|
|
3699
|
+
// exists). `retryable` is kept for observability.
|
|
3700
|
+
...this.classifyInferenceError(err),
|
|
3442
3701
|
});
|
|
3443
3702
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3444
3703
|
if (action.emit) {
|
|
@@ -3460,15 +3719,72 @@ export class AgentFramework {
|
|
|
3460
3719
|
const myStreamId = agent.streamId;
|
|
3461
3720
|
let hadToolCalls = false;
|
|
3462
3721
|
|
|
3722
|
+
// ---- Present-while-acting turn state ---------------------------------
|
|
3723
|
+
// Output locus for THIS turn: resolved lazily at the first routed prose
|
|
3724
|
+
// segment and pinned for the rest of the turn (mid-turn rounds AND the
|
|
3725
|
+
// trailing prose at 'complete'), so one turn's voice stays in one channel
|
|
3726
|
+
// even when a queued inbound from another channel overwrites the
|
|
3727
|
+
// per-agent triggering channel between segments (PR #32). The pin lives
|
|
3728
|
+
// in `turnLocusPins` so it survives a context-budget stream restart. A
|
|
3729
|
+
// null resolution is NOT pinned — a turn whose locus is unresolvable
|
|
3730
|
+
// early (e.g. heartbeat-triggered) may still resolve a channel by the
|
|
3731
|
+
// time later prose routes.
|
|
3732
|
+
if (trigger?.reason !== 'context_budget_restart') {
|
|
3733
|
+
this.turnLocusPins.delete(agent.name);
|
|
3734
|
+
}
|
|
3735
|
+
const resolveTurnLocus = (): string | null => {
|
|
3736
|
+
let locus = this.turnLocusPins.get(agent.name) ?? null;
|
|
3737
|
+
if (locus === null && this.channelRegistry) {
|
|
3738
|
+
locus = this.channelRegistry.resolveLocus(agent.name);
|
|
3739
|
+
if (locus !== null) this.turnLocusPins.set(agent.name, locus);
|
|
3740
|
+
}
|
|
3741
|
+
return locus;
|
|
3742
|
+
};
|
|
3743
|
+
|
|
3744
|
+
// Ordered delivery chain for live-routed prose. Links are enqueued
|
|
3745
|
+
// WITHOUT awaiting in the stream-event loop — an awaited network post
|
|
3746
|
+
// here would stall consumption of the next round's events by
|
|
3747
|
+
// segments × RTT on every prose-bearing round. The 'complete' case
|
|
3748
|
+
// awaits the chain before routing trailing prose, so in-channel ordering
|
|
3749
|
+
// is preserved end-to-end. Each link catches its own error: one failed
|
|
3750
|
+
// post must not silence the rest of the turn.
|
|
3751
|
+
let turnSpeechChain: Promise<void> = Promise.resolve();
|
|
3752
|
+
const enqueueSpeech = (text: string, locus: string | null): void => {
|
|
3753
|
+
turnSpeechChain = turnSpeechChain
|
|
3754
|
+
.then(async () => {
|
|
3755
|
+
await this.channelRegistry!.routeSpeech(agent.name, text, locus);
|
|
3756
|
+
})
|
|
3757
|
+
.catch((err) => console.error('mid-turn speech routing failed:', err));
|
|
3758
|
+
};
|
|
3759
|
+
|
|
3760
|
+
// Sticky silencing: once any round uses a SILENCING_TOOLS member,
|
|
3761
|
+
// prose from that round ONWARD is suppressed — skip_reply means "stay
|
|
3762
|
+
// silent", an explicit send means "I deliver my own words". Earlier
|
|
3763
|
+
// rounds' prose was already delivered live and cannot be retracted.
|
|
3764
|
+
let turnSilenced = false;
|
|
3765
|
+
|
|
3766
|
+
// Live routing is only trusted when the membrane provides verbatim
|
|
3767
|
+
// round-scoped blocks (roundContent, native tool mode, membrane ≥0.5.64).
|
|
3768
|
+
// The fallback `preamble` is CUMULATIVE in XML mode (assistant prefill +
|
|
3769
|
+
// all earlier rounds' prose and raw tool/thinking XML) — live-routing it
|
|
3770
|
+
// would repost the transcript and leak thinking every round. When no
|
|
3771
|
+
// round was live-routed, the 'complete' case falls back to the
|
|
3772
|
+
// historical whole-turn routing so fallback-mode prose is still
|
|
3773
|
+
// delivered exactly once, at turn end.
|
|
3774
|
+
let liveProseRouting = false;
|
|
3775
|
+
|
|
3463
3776
|
// Typing indicator: show "<agent> is typing…" in the channel she's
|
|
3464
3777
|
// responding to, for the whole duration of this turn. Started here (paired
|
|
3465
3778
|
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
3466
3779
|
// interval by the ChannelRegistry until stopped on any exit path.
|
|
3467
|
-
const typingChannel = this.channelRegistry
|
|
3780
|
+
const typingChannel = this.channelRegistry
|
|
3781
|
+
? trigger?.channelId ?? this.channelRegistry.getDefaultPublishChannel()
|
|
3782
|
+
: null;
|
|
3468
3783
|
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
3469
3784
|
|
|
3470
3785
|
try {
|
|
3471
3786
|
for await (const event of stream) {
|
|
3787
|
+
this.touchEphemeralRun(agent.name, true);
|
|
3472
3788
|
switch (event.type) {
|
|
3473
3789
|
case 'tokens':
|
|
3474
3790
|
this.emitTrace({
|
|
@@ -3494,6 +3810,7 @@ export class AgentFramework {
|
|
|
3494
3810
|
|
|
3495
3811
|
case 'tool-calls': {
|
|
3496
3812
|
hadToolCalls = true;
|
|
3813
|
+
this.recordEphemeralToolCalls(agent.name, event.calls.length);
|
|
3497
3814
|
this.emitTrace({
|
|
3498
3815
|
type: 'inference:tool_calls_yielded',
|
|
3499
3816
|
agentName: agent.name,
|
|
@@ -3535,6 +3852,43 @@ export class AgentFramework {
|
|
|
3535
3852
|
for (const call of event.calls) {
|
|
3536
3853
|
this.dispatchToolCall(agent.name, call);
|
|
3537
3854
|
}
|
|
3855
|
+
|
|
3856
|
+
// Speak-while-acting: route THIS round's prose to the locus NOW
|
|
3857
|
+
// instead of batching every segment to the end of the turn. Long
|
|
3858
|
+
// tool-using turns (robot control, live sessions) otherwise dump
|
|
3859
|
+
// the whole narration in one blob when the turn finally settles —
|
|
3860
|
+
// and, worse, make "talking" and "acting" feel mutually exclusive
|
|
3861
|
+
// to the agent. Mid-turn prose is speech (delivered live, in
|
|
3862
|
+
// order, via the turn's speech chain — never awaited here); the
|
|
3863
|
+
// `think` tool remains the private channel. Silencing is STICKY
|
|
3864
|
+
// from the round it occurs (see SILENCING_TOOLS). Only rounds
|
|
3865
|
+
// with verbatim roundContent are live-routed (see liveProseRouting
|
|
3866
|
+
// note above — the fallback preamble is cumulative in XML mode).
|
|
3867
|
+
if (this.channelRegistry) {
|
|
3868
|
+
const roundToolNames = event.calls.map((c) => c.name);
|
|
3869
|
+
if (roundToolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)))) {
|
|
3870
|
+
turnSilenced = true;
|
|
3871
|
+
}
|
|
3872
|
+
if (roundContent && roundContent.length > 0) {
|
|
3873
|
+
liveProseRouting = true;
|
|
3874
|
+
const roundSegments = splitProseSegments(assistantBlocks);
|
|
3875
|
+
if (roundSegments.length > 0) {
|
|
3876
|
+
if (turnSilenced) {
|
|
3877
|
+
console.error(
|
|
3878
|
+
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> prose NOT routed (turn silenced)`,
|
|
3879
|
+
);
|
|
3880
|
+
} else {
|
|
3881
|
+
const locus = resolveTurnLocus();
|
|
3882
|
+
console.error(
|
|
3883
|
+
`[routing] ${agent.name}: mid-turn round [${roundToolNames.join(', ')}] -> routing ${roundSegments.length} prose segment(s) live -> ${locus ?? '(default)'}`,
|
|
3884
|
+
);
|
|
3885
|
+
for (const seg of roundSegments) {
|
|
3886
|
+
enqueueSpeech(seg, locus);
|
|
3887
|
+
}
|
|
3888
|
+
}
|
|
3889
|
+
}
|
|
3890
|
+
}
|
|
3891
|
+
}
|
|
3538
3892
|
// Stream's async iterator blocks on next() until provideToolResults() is called
|
|
3539
3893
|
break;
|
|
3540
3894
|
}
|
|
@@ -3593,13 +3947,15 @@ export class AgentFramework {
|
|
|
3593
3947
|
b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
|
|
3594
3948
|
-1
|
|
3595
3949
|
);
|
|
3950
|
+
const terminalContent = lastToolIdx >= 0
|
|
3951
|
+
? response.content.slice(lastToolIdx + 1)
|
|
3952
|
+
: response.content;
|
|
3596
3953
|
if (lastToolIdx >= 0) {
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
agent.addAssistantResponse(trailingContent);
|
|
3954
|
+
if (terminalContent.length > 0) {
|
|
3955
|
+
agent.addAssistantResponse(terminalContent);
|
|
3600
3956
|
}
|
|
3601
3957
|
} else {
|
|
3602
|
-
agent.addAssistantResponse(
|
|
3958
|
+
agent.addAssistantResponse(terminalContent);
|
|
3603
3959
|
}
|
|
3604
3960
|
|
|
3605
3961
|
// Run afterInference hooks (no-op if no MCPL servers)
|
|
@@ -3637,10 +3993,13 @@ export class AgentFramework {
|
|
|
3637
3993
|
}
|
|
3638
3994
|
}
|
|
3639
3995
|
|
|
3640
|
-
// Separate speech from thoughts
|
|
3641
|
-
//
|
|
3642
|
-
//
|
|
3643
|
-
//
|
|
3996
|
+
// Separate speech from thoughts — for MODULE dispatch only
|
|
3997
|
+
// (dispatchSpeech / TUI rendering below). This split does NOT
|
|
3998
|
+
// decide what reaches the channel: mid-turn prose is routed live
|
|
3999
|
+
// per round (see the 'tool-calls' case) and trailing prose is
|
|
4000
|
+
// routed after this block. On a tool-using turn the module-level
|
|
4001
|
+
// convention remains "text is thoughts"; on a text-only turn all
|
|
4002
|
+
// text is speech.
|
|
3644
4003
|
const isTextBlock = (block: ContentBlock): block is ContentBlock & { type: 'text' } =>
|
|
3645
4004
|
block.type === 'text';
|
|
3646
4005
|
const allText = response.content.filter(isTextBlock);
|
|
@@ -3658,17 +4017,20 @@ export class AgentFramework {
|
|
|
3658
4017
|
}
|
|
3659
4018
|
: undefined;
|
|
3660
4019
|
|
|
3661
|
-
// Reset agent state
|
|
3662
|
-
// synchronous listeners
|
|
3663
|
-
//
|
|
3664
|
-
//
|
|
3665
|
-
//
|
|
3666
|
-
// trace boundary, failed its idle gate, and the promise hung
|
|
3667
|
-
// forever — every "zombie subagent" in production tracked back
|
|
3668
|
-
// here. Speech dispatch happens after but doesn't depend on the
|
|
3669
|
-
// status field.
|
|
4020
|
+
// Reset agent state before emitting inference:completed. Traces are
|
|
4021
|
+
// observability-only, but external synchronous listeners should
|
|
4022
|
+
// still see the terminal state at the terminal trace boundary.
|
|
4023
|
+
// Speech dispatch happens after but doesn't depend on the status
|
|
4024
|
+
// field.
|
|
3670
4025
|
agent.reset();
|
|
3671
4026
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
4027
|
+
this.settleAgent(agent.name, {
|
|
4028
|
+
stopReason: 'completed',
|
|
4029
|
+
speech: terminalContent
|
|
4030
|
+
.filter((block: ContentBlock): block is ContentBlock & { type: 'text' } => block.type === 'text')
|
|
4031
|
+
.map((block) => block.text)
|
|
4032
|
+
.join('\n'),
|
|
4033
|
+
});
|
|
3672
4034
|
|
|
3673
4035
|
this.emitTrace({
|
|
3674
4036
|
type: 'inference:completed',
|
|
@@ -3837,79 +4199,62 @@ export class AgentFramework {
|
|
|
3837
4199
|
}
|
|
3838
4200
|
}
|
|
3839
4201
|
} else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
3840
|
-
// Tool-call turn that also produced prose.
|
|
3841
|
-
//
|
|
3842
|
-
//
|
|
3843
|
-
//
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
// turn
|
|
3847
|
-
//
|
|
3848
|
-
// reaches the channel.
|
|
3849
|
-
//
|
|
3850
|
-
//
|
|
3851
|
-
//
|
|
3852
|
-
//
|
|
3853
|
-
//
|
|
3854
|
-
// *reply_message / *send_dm) — already sent the message, so
|
|
3855
|
-
// routing it again would double-post.
|
|
3856
|
-
// `think` is deliberately NOT here: it is silent *reasoning*, but
|
|
3857
|
-
// prose written after a think is the agent's actual reply and must
|
|
3858
|
-
// be delivered. (A think-only turn has no trailing prose and stays
|
|
3859
|
-
// silent via the `!t` check below.)
|
|
3860
|
-
const SILENCING = new Set([
|
|
3861
|
-
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
3862
|
-
]);
|
|
3863
|
-
const bare = (n: string) => (n.includes('--') ? n.split('--').pop()! : n);
|
|
4202
|
+
// Tool-call turn that also produced prose. When live routing was
|
|
4203
|
+
// active (native roundContent), each mid-turn round's prose was
|
|
4204
|
+
// already delivered when that round yielded — only the TRAILING
|
|
4205
|
+
// prose (blocks after the last tool round) remains here. On the
|
|
4206
|
+
// fallback path (XML tool mode / older membrane: no roundContent,
|
|
4207
|
+
// so nothing was live-routed) the historical behavior applies:
|
|
4208
|
+
// route the whole turn's parsed segments, once, now. The global
|
|
4209
|
+
// speech/thoughts split is left untouched (module/TUI rendering
|
|
4210
|
+
// unaffected) — this only governs what reaches the channel.
|
|
4211
|
+
//
|
|
4212
|
+
// Silencing here is the sticky flag ∪ turn-wide scan: the scan
|
|
4213
|
+
// (over the whole turn's tool_use blocks) covers the fallback
|
|
4214
|
+
// path and the "send_message then a 'sent it.' postscript"
|
|
4215
|
+
// double-post guard; the sticky flag is its live-rounds twin.
|
|
3864
4216
|
const toolNames = response.content
|
|
3865
4217
|
.filter((b) => b.type === 'tool_use')
|
|
3866
4218
|
.map((b) => (b as unknown as { name?: string }).name)
|
|
3867
4219
|
.filter((n): n is string => typeof n === 'string');
|
|
3868
|
-
const silenced = toolNames.some((n) =>
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
//
|
|
3873
|
-
//
|
|
3874
|
-
//
|
|
3875
|
-
|
|
3876
|
-
// ("msgA → [tool] → msgB → [tool] → msgC") is then delivered as
|
|
3877
|
-
// separate messages IN ORDER instead of being collapsed into one
|
|
3878
|
-
// trailing post (item 4). The silencing rule stays turn-wide: an
|
|
3879
|
-
// explicit send / skip suppresses ALL auto-routed prose this turn,
|
|
3880
|
-
// preserving the double-post guard.
|
|
3881
|
-
const segments = splitProseSegments(response.content);
|
|
4220
|
+
const silenced = turnSilenced || toolNames.some((n) => SILENCING_TOOLS.has(bareToolName(n)));
|
|
4221
|
+
|
|
4222
|
+
const segments = splitProseSegments(liveProseRouting ? terminalContent : response.content);
|
|
4223
|
+
|
|
4224
|
+
// Preserve in-channel ordering: everything enqueued live must
|
|
4225
|
+
// land before the trailing prose. Awaited even when silenced —
|
|
4226
|
+
// the chain may still be flushing earlier rounds' posts.
|
|
4227
|
+
await turnSpeechChain;
|
|
3882
4228
|
|
|
3883
4229
|
if (silenced || segments.length === 0) {
|
|
3884
4230
|
console.error(
|
|
3885
|
-
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
3886
|
-
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
|
|
4231
|
+
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> trailing prose NOT routed ` +
|
|
4232
|
+
`(${silenced ? 'silencing tool / explicit send' : 'no trailing prose'})`,
|
|
3887
4233
|
);
|
|
3888
4234
|
} else {
|
|
3889
|
-
//
|
|
3890
|
-
//
|
|
3891
|
-
//
|
|
3892
|
-
//
|
|
3893
|
-
//
|
|
3894
|
-
//
|
|
3895
|
-
//
|
|
3896
|
-
const
|
|
4235
|
+
// Reuse the locus pinned at the turn's first live-routed
|
|
4236
|
+
// segment (or resolve it now for a turn whose only prose is
|
|
4237
|
+
// trailing). This dispatch runs AFTER the agent is idle (see
|
|
4238
|
+
// PR #32 note below), so a queued inbound from another channel
|
|
4239
|
+
// could otherwise overwrite the per-agent triggering channel
|
|
4240
|
+
// between segments — and a turn that narrated live into one
|
|
4241
|
+
// channel must not land its postscript in another.
|
|
4242
|
+
const locus = resolveTurnLocus();
|
|
3897
4243
|
console.error(
|
|
3898
|
-
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} prose segment(s)
|
|
4244
|
+
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing ${segments.length} ${liveProseRouting ? 'trailing ' : ''}prose segment(s) -> ${locus ?? '(default)'}`,
|
|
3899
4245
|
);
|
|
3900
4246
|
// Deliver sequentially (await each) so the segments land in order.
|
|
3901
4247
|
for (const seg of segments) {
|
|
3902
4248
|
try {
|
|
3903
|
-
await this.channelRegistry.routeSpeech(agent.name, seg,
|
|
4249
|
+
await this.channelRegistry.routeSpeech(agent.name, seg, locus);
|
|
3904
4250
|
} catch (err) {
|
|
3905
4251
|
console.error('speech routing failed:', err);
|
|
3906
4252
|
}
|
|
3907
4253
|
}
|
|
3908
4254
|
}
|
|
3909
4255
|
}
|
|
3910
|
-
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
3911
|
-
// dispatchSpeech
|
|
3912
|
-
// must see status === 'idle'). Locus routing is speech dispatch and
|
|
4256
|
+
// NOTE: agent.reset() + onInferenceEnded() already ran above,
|
|
4257
|
+
// BEFORE dispatchSpeech. Locus routing is speech dispatch and
|
|
3913
4258
|
// doesn't depend on the status field, so it correctly runs after.
|
|
3914
4259
|
|
|
3915
4260
|
break;
|
|
@@ -3945,16 +4290,20 @@ export class AgentFramework {
|
|
|
3945
4290
|
await new Promise((resolve) => setTimeout(resolve, action.delayMs));
|
|
3946
4291
|
await this.startAgentStream(agent, trigger, attempt + 1);
|
|
3947
4292
|
} else {
|
|
4293
|
+
this.settleAgent(agent.name, {
|
|
4294
|
+
stopReason: 'exhausted',
|
|
4295
|
+
speech: '',
|
|
4296
|
+
error: err.message,
|
|
4297
|
+
});
|
|
3948
4298
|
this.emitTrace({
|
|
3949
4299
|
type: 'inference:exhausted',
|
|
3950
4300
|
agentName: agent.name,
|
|
3951
4301
|
error: err.message,
|
|
3952
4302
|
// Drives the poison-history breaker. `retryable` is kept for
|
|
3953
|
-
// observability, but the
|
|
4303
|
+
// observability, but the breakers gate on `errorType` — only an
|
|
3954
4304
|
// `invalid_request` (a 400-class rejection of the history
|
|
3955
4305
|
// itself) means retrying the same context can never succeed.
|
|
3956
|
-
|
|
3957
|
-
errorType: err instanceof MembraneError ? err.type : undefined,
|
|
4306
|
+
...this.classifyInferenceError(err),
|
|
3958
4307
|
});
|
|
3959
4308
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
3960
4309
|
if (action.emit) {
|
|
@@ -3965,12 +4314,28 @@ export class AgentFramework {
|
|
|
3965
4314
|
}
|
|
3966
4315
|
|
|
3967
4316
|
case 'aborted': {
|
|
4317
|
+
// Framework-initiated, non-terminal cancels (endTurn tool result,
|
|
4318
|
+
// context-budget restart) also surface here as `aborted` — but
|
|
4319
|
+
// the turn either already settled (endTurn) or a replacement
|
|
4320
|
+
// stream is queued (budget restart). Neither is a failure: no
|
|
4321
|
+
// reset, no settle, no spurious inference:exhausted (which would
|
|
4322
|
+
// reject an ephemeral's promise mid-run and bump the failure
|
|
4323
|
+
// streak). Gate release + stream teardown happen in `finally`.
|
|
4324
|
+
if (this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`)) {
|
|
4325
|
+
this.eventGate?.onInferenceEnded(agent.name);
|
|
4326
|
+
return;
|
|
4327
|
+
}
|
|
3968
4328
|
const reason = event.reason ?? 'unknown';
|
|
3969
4329
|
// Only reset if this is still the active stream (a budget restart
|
|
3970
4330
|
// may have already started a new stream, bumping streamId)
|
|
3971
4331
|
if (agent.streamId === myStreamId) {
|
|
3972
4332
|
const durationMs = Date.now() - startTime;
|
|
3973
4333
|
agent.reset();
|
|
4334
|
+
this.settleAgent(agent.name, {
|
|
4335
|
+
stopReason: 'exhausted',
|
|
4336
|
+
speech: '',
|
|
4337
|
+
error: `Stream aborted: ${reason}`,
|
|
4338
|
+
});
|
|
3974
4339
|
this.emitTrace({
|
|
3975
4340
|
type: 'inference:exhausted',
|
|
3976
4341
|
agentName: agent.name,
|
|
@@ -4046,10 +4411,16 @@ export class AgentFramework {
|
|
|
4046
4411
|
error: err.message,
|
|
4047
4412
|
stack: err.stack,
|
|
4048
4413
|
});
|
|
4414
|
+
this.settleAgent(agent.name, {
|
|
4415
|
+
stopReason: 'exhausted',
|
|
4416
|
+
speech: '',
|
|
4417
|
+
error: err.message,
|
|
4418
|
+
});
|
|
4049
4419
|
this.emitTrace({
|
|
4050
4420
|
type: 'inference:exhausted',
|
|
4051
4421
|
agentName: agent.name,
|
|
4052
4422
|
error: err.message,
|
|
4423
|
+
...this.classifyInferenceError(err),
|
|
4053
4424
|
});
|
|
4054
4425
|
// Postmortem 2026-05-28 P2 #7: catch-path failures (stream itself
|
|
4055
4426
|
// threw) were previously only visible via in-memory trace listeners.
|
|
@@ -4079,6 +4450,7 @@ export class AgentFramework {
|
|
|
4079
4450
|
// Stop the typing indicator on every exit path (complete, error,
|
|
4080
4451
|
// exhausted, abort) so it never sticks after the turn ends.
|
|
4081
4452
|
this.channelRegistry?.stopTyping();
|
|
4453
|
+
this.frameworkCancelledStreams.delete(`${agent.name}:${myStreamId}`);
|
|
4082
4454
|
this.activeStreams.delete(agent.name);
|
|
4083
4455
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
4084
4456
|
|
|
@@ -4298,6 +4670,12 @@ export class AgentFramework {
|
|
|
4298
4670
|
return;
|
|
4299
4671
|
}
|
|
4300
4672
|
|
|
4673
|
+
// Route the agent's typed, allowlisted hot-settings surface.
|
|
4674
|
+
if (enrichedCall.name === 'agent_settings') {
|
|
4675
|
+
this.dispatchAgentSettingsToolCall(agentName, enrichedCall);
|
|
4676
|
+
return;
|
|
4677
|
+
}
|
|
4678
|
+
|
|
4301
4679
|
// Route gate_status tool
|
|
4302
4680
|
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
4303
4681
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
@@ -4416,12 +4794,19 @@ export class AgentFramework {
|
|
|
4416
4794
|
throw new Error('No agents configured');
|
|
4417
4795
|
}
|
|
4418
4796
|
|
|
4419
|
-
//
|
|
4420
|
-
//
|
|
4797
|
+
// Defer non-tool_result messages while a tool cycle is mid-flight
|
|
4798
|
+
// (pendingAssistantBlocks: preserves tool_use → tool_result adjacency)
|
|
4799
|
+
// OR while the target agent has a live stream at all. A message stored
|
|
4800
|
+
// mid-stream lands BEFORE the turn's assistant blocks in the window even
|
|
4801
|
+
// though the live conversation never saw it — so the next compile
|
|
4802
|
+
// diverges from the live prefix (prompt-cache bust) and the message
|
|
4803
|
+
// misses mid-turn injection. Deferred messages flush at the agent's next
|
|
4804
|
+
// tool boundary (where they are ALSO injected into the live stream —
|
|
4805
|
+
// hear-while-acting) or in driveStream's finally when the turn ends.
|
|
4421
4806
|
const hasToolResult = content.some(b => b.type === 'tool_result');
|
|
4422
|
-
if (!hasToolResult && this.pendingAssistantBlocks.size > 0) {
|
|
4807
|
+
if (!hasToolResult && (this.pendingAssistantBlocks.size > 0 || this.activeStreams.has(agent.name))) {
|
|
4423
4808
|
this.deferredMessages.push({ participant, content, metadata });
|
|
4424
|
-
return '' as MessageId; // Deferred —
|
|
4809
|
+
return '' as MessageId; // Deferred — flushed at the next boundary
|
|
4425
4810
|
}
|
|
4426
4811
|
|
|
4427
4812
|
return agent.getContextManager().addMessage(participant, content, metadata);
|
|
@@ -4667,6 +5052,28 @@ export class AgentFramework {
|
|
|
4667
5052
|
return streak;
|
|
4668
5053
|
}
|
|
4669
5054
|
|
|
5055
|
+
/**
|
|
5056
|
+
* Classify a terminal inference error for the `inference:exhausted` trace.
|
|
5057
|
+
* Single-sourced for every emit site: the downstream breakers (poison-history
|
|
5058
|
+
* quarantine, OverBudget drain kick) gate on `errorType`, so classification
|
|
5059
|
+
* drift between sites would silently disable a safety net.
|
|
5060
|
+
*
|
|
5061
|
+
* context-manager's OverBudgetError is recognized by `err.name`: CM does not
|
|
5062
|
+
* export the class from its package root, so a cross-package `instanceof` is
|
|
5063
|
+
* unavailable — but `name` is set in its constructor and survives the package
|
|
5064
|
+
* boundary. Deliberately NOT a message match: the message wording belongs to
|
|
5065
|
+
* CM and can be reworded without warning.
|
|
5066
|
+
*/
|
|
5067
|
+
private classifyInferenceError(err: Error): { retryable?: boolean; errorType?: string } {
|
|
5068
|
+
if (err instanceof MembraneError) {
|
|
5069
|
+
return { retryable: err.retryable, errorType: err.type };
|
|
5070
|
+
}
|
|
5071
|
+
if (err.name === 'OverBudgetError') {
|
|
5072
|
+
return { errorType: 'over_budget' };
|
|
5073
|
+
}
|
|
5074
|
+
return {};
|
|
5075
|
+
}
|
|
5076
|
+
|
|
4670
5077
|
private noteInferenceExhausted(
|
|
4671
5078
|
agentName: string,
|
|
4672
5079
|
reason: string,
|
|
@@ -4707,6 +5114,41 @@ export class AgentFramework {
|
|
|
4707
5114
|
}
|
|
4708
5115
|
}
|
|
4709
5116
|
|
|
5117
|
+
// (2b) OverBudget deadlock breaker. When compile fails with
|
|
5118
|
+
// OverBudgetError, the normal compression drain never runs: it is driven
|
|
5119
|
+
// by successful activity, which the over-budget state prevents - a closed
|
|
5120
|
+
// loop with no internal exit. Field data (2026-07-10, resident agent):
|
|
5121
|
+
// 36 minutes hard-down, zero self-rescue; only an operator raising the
|
|
5122
|
+
// budget externally could break the loop. Break it here instead: kick the
|
|
5123
|
+
// strategy drain directly so folding/merging frees space for the next
|
|
5124
|
+
// compile. Bounded ticks, best-effort, never throws, one kick per agent
|
|
5125
|
+
// at a time (see overBudgetDrainInFlight).
|
|
5126
|
+
//
|
|
5127
|
+
// Gates on the classified errorType (see classifyInferenceError); the
|
|
5128
|
+
// message match is only a fallback for paths that lost the Error object
|
|
5129
|
+
// (e.g. a reason string that crossed a serialization boundary). It matches
|
|
5130
|
+
// CM's current OverBudgetError wording and MAY rot if CM rewords it — the
|
|
5131
|
+
// errorType gate is the one that's load-bearing.
|
|
5132
|
+
const overBudget = errorType === 'over_budget' || /exceed hard budget/i.test(reason);
|
|
5133
|
+
if (agent && overBudget && !this.overBudgetDrainInFlight.has(agentName)) {
|
|
5134
|
+
this.overBudgetDrainInFlight.add(agentName);
|
|
5135
|
+
void (async () => {
|
|
5136
|
+
let ticks = 0;
|
|
5137
|
+
try {
|
|
5138
|
+
const cm = agent.getContextManager();
|
|
5139
|
+
while (ticks < 8) {
|
|
5140
|
+
await cm.tick();
|
|
5141
|
+
ticks++;
|
|
5142
|
+
}
|
|
5143
|
+
console.error(`[inference-failed] drain kicked for ${agentName} (OverBudget breaker, ${ticks} ticks)`);
|
|
5144
|
+
} catch (err) {
|
|
5145
|
+
console.error(`[inference-failed] drain kick failed for ${agentName} after ${ticks} ticks:`, err);
|
|
5146
|
+
} finally {
|
|
5147
|
+
this.overBudgetDrainInFlight.delete(agentName);
|
|
5148
|
+
}
|
|
5149
|
+
})();
|
|
5150
|
+
}
|
|
5151
|
+
|
|
4710
5152
|
// (3) Hard-down escalation on repeated identical failure.
|
|
4711
5153
|
if (streak >= this.inferenceFailureEscalationThreshold) {
|
|
4712
5154
|
console.error(
|
|
@@ -4852,6 +5294,7 @@ export class AgentFramework {
|
|
|
4852
5294
|
(event) => this.pushEvent(event),
|
|
4853
5295
|
(event) => this.emitTrace(event as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
4854
5296
|
{
|
|
5297
|
+
store: this.store,
|
|
4855
5298
|
sendTypingFn: (serverId, channelId, metadata, op) => {
|
|
4856
5299
|
const server = this.mcplServerRegistry!.getServer(serverId);
|
|
4857
5300
|
if (server) {
|
|
@@ -4956,8 +5399,11 @@ export class AgentFramework {
|
|
|
4956
5399
|
|
|
4957
5400
|
// Record per-server channel subscription policy before the server
|
|
4958
5401
|
// registers channels — handleRegister fires during the handshake.
|
|
4959
|
-
if (
|
|
4960
|
-
this.channelRegistry.setSubscriptionPolicy(
|
|
5402
|
+
if (this.channelRegistry) {
|
|
5403
|
+
this.channelRegistry.setSubscriptionPolicy(
|
|
5404
|
+
config.id,
|
|
5405
|
+
config.channelSubscription ?? 'manual',
|
|
5406
|
+
);
|
|
4961
5407
|
}
|
|
4962
5408
|
|
|
4963
5409
|
const connection = await this.mcplServerRegistry.addServer(config, this.mcplHostCapabilities);
|
|
@@ -5782,6 +6228,88 @@ export class AgentFramework {
|
|
|
5782
6228
|
});
|
|
5783
6229
|
}
|
|
5784
6230
|
|
|
6231
|
+
private dispatchAgentSettingsToolCall(agentName: string, call: ToolCall): void {
|
|
6232
|
+
this.emitTrace({
|
|
6233
|
+
type: 'tool:started',
|
|
6234
|
+
module: 'agent',
|
|
6235
|
+
tool: call.name,
|
|
6236
|
+
callId: call.id,
|
|
6237
|
+
input: call.input,
|
|
6238
|
+
});
|
|
6239
|
+
let result: ToolResult;
|
|
6240
|
+
try {
|
|
6241
|
+
const input = (call.input ?? {}) as {
|
|
6242
|
+
action?: unknown;
|
|
6243
|
+
context_budget_tokens?: unknown;
|
|
6244
|
+
tail_tokens?: unknown;
|
|
6245
|
+
transition_pace_tokens?: unknown;
|
|
6246
|
+
settings?: unknown;
|
|
6247
|
+
};
|
|
6248
|
+
switch (input.action) {
|
|
6249
|
+
case 'get':
|
|
6250
|
+
result = { success: true, data: this.getAgentRuntimeSettings(agentName) };
|
|
6251
|
+
break;
|
|
6252
|
+
case 'cancel':
|
|
6253
|
+
result = { success: true, data: this.cancelAgentRuntimeSettingsTransition(agentName) };
|
|
6254
|
+
break;
|
|
6255
|
+
case 'update': {
|
|
6256
|
+
const patch: AgentRuntimeSettingsPatch = {};
|
|
6257
|
+
if (input.context_budget_tokens !== undefined) {
|
|
6258
|
+
patch.contextBudgetTokens = Number(input.context_budget_tokens);
|
|
6259
|
+
}
|
|
6260
|
+
if (input.tail_tokens !== undefined) patch.tailTokens = Number(input.tail_tokens);
|
|
6261
|
+
if (input.transition_pace_tokens !== undefined) {
|
|
6262
|
+
patch.transitionPaceTokens = Number(input.transition_pace_tokens);
|
|
6263
|
+
}
|
|
6264
|
+
result = { success: true, data: this.updateAgentRuntimeSettings(agentName, patch) };
|
|
6265
|
+
break;
|
|
6266
|
+
}
|
|
6267
|
+
case 'reset': {
|
|
6268
|
+
let keys: Array<keyof AgentRuntimeSettingsPatch> | undefined;
|
|
6269
|
+
if (input.settings !== undefined) {
|
|
6270
|
+
if (!Array.isArray(input.settings)) throw new Error('reset `settings` must be an array');
|
|
6271
|
+
const names: Record<string, keyof AgentRuntimeSettingsPatch> = {
|
|
6272
|
+
context_budget_tokens: 'contextBudgetTokens',
|
|
6273
|
+
tail_tokens: 'tailTokens',
|
|
6274
|
+
transition_pace_tokens: 'transitionPaceTokens',
|
|
6275
|
+
};
|
|
6276
|
+
keys = input.settings.map((name) => {
|
|
6277
|
+
if (typeof name !== 'string' || !names[name]) {
|
|
6278
|
+
throw new Error(`Unknown reset setting: ${String(name)}`);
|
|
6279
|
+
}
|
|
6280
|
+
return names[name];
|
|
6281
|
+
});
|
|
6282
|
+
}
|
|
6283
|
+
result = { success: true, data: this.resetAgentRuntimeSettings(agentName, keys) };
|
|
6284
|
+
break;
|
|
6285
|
+
}
|
|
6286
|
+
default:
|
|
6287
|
+
throw new Error('agent_settings: action must be get, update, reset, or cancel');
|
|
6288
|
+
}
|
|
6289
|
+
} catch (error) {
|
|
6290
|
+
result = {
|
|
6291
|
+
success: false,
|
|
6292
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6293
|
+
isError: true,
|
|
6294
|
+
};
|
|
6295
|
+
}
|
|
6296
|
+
this.emitTrace({
|
|
6297
|
+
type: result.isError ? 'tool:failed' : 'tool:completed',
|
|
6298
|
+
module: 'agent',
|
|
6299
|
+
tool: call.name,
|
|
6300
|
+
callId: call.id,
|
|
6301
|
+
durationMs: 0,
|
|
6302
|
+
...(result.isError ? { error: result.error } : {}),
|
|
6303
|
+
});
|
|
6304
|
+
this.pushEvent({
|
|
6305
|
+
type: 'tool-result',
|
|
6306
|
+
callId: call.id,
|
|
6307
|
+
agentName,
|
|
6308
|
+
moduleName: 'agent',
|
|
6309
|
+
result,
|
|
6310
|
+
});
|
|
6311
|
+
}
|
|
6312
|
+
|
|
5785
6313
|
/** Aggregate the event-tag vocabulary: reserved chat:* core + each connected
|
|
5786
6314
|
* server's declared tag ontology + gate.js status. */
|
|
5787
6315
|
private buildEventTagsResult(): Record<string, unknown> {
|