@animalabs/agent-framework 0.6.0 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/agent.d.ts +4 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +15 -0
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +235 -2
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1141 -92
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +20 -1
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +60 -4
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +17 -1
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +34 -3
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +4 -0
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +29 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +102 -4
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +17 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/module-registry.d.ts +15 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +92 -4
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/types/agent.d.ts +37 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +40 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +46 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +51 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/agent-cache-ttl.test.d.ts +2 -0
- package/dist/test/agent-cache-ttl.test.d.ts.map +1 -0
- package/dist/test/agent-cache-ttl.test.js +25 -0
- package/dist/test/agent-cache-ttl.test.js.map +1 -0
- package/dist/test/conversation-fork-injection-scoping.test.d.ts +2 -0
- package/dist/test/conversation-fork-injection-scoping.test.d.ts.map +1 -0
- package/dist/test/conversation-fork-injection-scoping.test.js +184 -0
- package/dist/test/conversation-fork-injection-scoping.test.js.map +1 -0
- package/dist/test/gather-context-timeout.test.d.ts +2 -0
- package/dist/test/gather-context-timeout.test.d.ts.map +1 -0
- package/dist/test/gather-context-timeout.test.js +120 -0
- package/dist/test/gather-context-timeout.test.js.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.d.ts +2 -0
- package/dist/test/inference-exhaustion-rewind.test.d.ts.map +1 -0
- package/dist/test/inference-exhaustion-rewind.test.js +181 -0
- package/dist/test/inference-exhaustion-rewind.test.js.map +1 -0
- package/dist/test/inference-failure-observability.test.js +9 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -1
- package/dist/test/maintenance-timer.test.d.ts +2 -0
- package/dist/test/maintenance-timer.test.d.ts.map +1 -0
- package/dist/test/maintenance-timer.test.js +114 -0
- package/dist/test/maintenance-timer.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.d.ts +2 -0
- package/dist/test/mcpl-reconnect-events.test.d.ts.map +1 -0
- package/dist/test/mcpl-reconnect-events.test.js +119 -0
- package/dist/test/mcpl-reconnect-events.test.js.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.d.ts +2 -0
- package/dist/test/mcpl-reconnect-reregistration.test.d.ts.map +1 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js +181 -0
- package/dist/test/mcpl-reconnect-reregistration.test.js.map +1 -0
- package/dist/test/mcpl-request-timeout.test.d.ts +2 -0
- package/dist/test/mcpl-request-timeout.test.d.ts.map +1 -0
- package/dist/test/mcpl-request-timeout.test.js +123 -0
- package/dist/test/mcpl-request-timeout.test.js.map +1 -0
- package/dist/test/mcpl-runtime-lifecycle.test.d.ts +7 -0
- package/dist/test/mcpl-runtime-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-runtime-lifecycle.test.js +142 -0
- package/dist/test/mcpl-runtime-lifecycle.test.js.map +1 -0
- package/dist/test/ops-alert.test.d.ts +2 -0
- package/dist/test/ops-alert.test.d.ts.map +1 -0
- package/dist/test/ops-alert.test.js +152 -0
- package/dist/test/ops-alert.test.js.map +1 -0
- package/dist/test/refusal-rewind.test.d.ts +2 -0
- package/dist/test/refusal-rewind.test.d.ts.map +1 -0
- package/dist/test/refusal-rewind.test.js +108 -0
- package/dist/test/refusal-rewind.test.js.map +1 -0
- package/dist/test/state-scaling-e2e.test.d.ts +22 -0
- package/dist/test/state-scaling-e2e.test.d.ts.map +1 -0
- package/dist/test/state-scaling-e2e.test.js +200 -0
- package/dist/test/state-scaling-e2e.test.js.map +1 -0
- package/dist/test/state-scaling.test.d.ts +12 -0
- package/dist/test/state-scaling.test.d.ts.map +1 -0
- package/dist/test/state-scaling.test.js +243 -0
- package/dist/test/state-scaling.test.js.map +1 -0
- package/dist/test/tool-result-content.test.d.ts +2 -0
- package/dist/test/tool-result-content.test.d.ts.map +1 -0
- package/dist/test/tool-result-content.test.js +37 -0
- package/dist/test/tool-result-content.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -2
- package/src/agent.ts +16 -0
- package/src/framework.ts +1339 -99
- package/src/gate/event-gate.ts +56 -4
- package/src/index.ts +10 -0
- package/src/mcpl/channel-registry.ts +34 -3
- package/src/mcpl/push-handler.ts +4 -0
- package/src/mcpl/server-connection.ts +110 -4
- package/src/mcpl/types.ts +19 -1
- package/src/module-registry.ts +113 -4
- package/src/types/agent.ts +38 -0
- package/src/types/framework.ts +44 -0
- package/src/types/module.ts +52 -0
- package/src/types/trace.ts +67 -0
package/src/gate/event-gate.ts
CHANGED
|
@@ -344,12 +344,16 @@ export class EventGate {
|
|
|
344
344
|
private defaultSkipped = 0;
|
|
345
345
|
private defaultByEventType = new Map<string, { triggered: number; skipped: number }>();
|
|
346
346
|
|
|
347
|
-
// Sleep state (set via the `sleep` tool).
|
|
348
|
-
//
|
|
349
|
-
//
|
|
347
|
+
// Sleep state (set via the `sleep` tool). evaluate() suppresses non-privileged
|
|
348
|
+
// wakes while now() < sleepUntil. A self-wake timer (sleepTimer) fires an
|
|
349
|
+
// inference when the window expires, so `sleep(N)` means "wake me in N
|
|
350
|
+
// seconds" rather than merely lifting suppression and waiting for an external
|
|
351
|
+
// event.
|
|
350
352
|
private sleepUntil = 0;
|
|
351
353
|
private sleepNote: string | undefined;
|
|
352
354
|
private sleepSuppressed = 0;
|
|
355
|
+
private sleepTimer: ReturnType<typeof setTimeout> | null = null;
|
|
356
|
+
private sleepAgent: string | undefined;
|
|
353
357
|
|
|
354
358
|
// Privileged users who may wake the agent through sleep. Hot-reloaded from
|
|
355
359
|
// privilegedUsersPath on change.
|
|
@@ -1098,6 +1102,18 @@ export class EventGate {
|
|
|
1098
1102
|
}
|
|
1099
1103
|
}
|
|
1100
1104
|
|
|
1105
|
+
/** Live inference-gating snapshot for diagnostics (e.g. a SIGUSR2 dump).
|
|
1106
|
+
* A non-empty `inferring` with a stale timestamp + growing `bufferedEvents`
|
|
1107
|
+
* is the signature of the wake-wedge: an agent stuck mid-"inference" so all
|
|
1108
|
+
* incoming events buffer and never trigger. */
|
|
1109
|
+
inferenceDiagnostics(): { inferring: string[]; bufferedEvents: number; sleepUntil: number } {
|
|
1110
|
+
return {
|
|
1111
|
+
inferring: [...this.inferring],
|
|
1112
|
+
bufferedEvents: this.inferenceBuffer.length,
|
|
1113
|
+
sleepUntil: this.sleepUntil,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1101
1117
|
// =========================================================================
|
|
1102
1118
|
// Inference buffer cap
|
|
1103
1119
|
// =========================================================================
|
|
@@ -1121,11 +1137,17 @@ export class EventGate {
|
|
|
1121
1137
|
// =========================================================================
|
|
1122
1138
|
|
|
1123
1139
|
/** Begin (or extend) a sleep window of `seconds`. Returns the wake time. */
|
|
1124
|
-
setSleep(seconds: number, note?: string): { until: number } {
|
|
1140
|
+
setSleep(seconds: number, note?: string, agentName?: string): { until: number } {
|
|
1125
1141
|
const ms = Math.max(0, Math.floor(seconds * 1000));
|
|
1126
1142
|
this.sleepUntil = this.now() + ms;
|
|
1127
1143
|
this.sleepNote = note;
|
|
1144
|
+
this.sleepAgent = agentName;
|
|
1128
1145
|
this.sleepSuppressed = 0;
|
|
1146
|
+
// Arm the self-wake: when the window elapses, clear sleep and request an
|
|
1147
|
+
// inference so the agent resumes on its own (not dependent on a heartbeat
|
|
1148
|
+
// tick or an incoming message). Cancelled by clearSleep()/`wake`.
|
|
1149
|
+
if (this.sleepTimer) clearTimeout(this.sleepTimer);
|
|
1150
|
+
this.sleepTimer = setTimeout(() => this.wakeFromSleep('sleep-expired'), ms);
|
|
1129
1151
|
this.reloadPrivilegedIfChanged();
|
|
1130
1152
|
this.emitTrace({
|
|
1131
1153
|
type: 'gate:decision',
|
|
@@ -1143,9 +1165,39 @@ export class EventGate {
|
|
|
1143
1165
|
const wasAsleep = this.sleepUntil > this.now();
|
|
1144
1166
|
this.sleepUntil = 0;
|
|
1145
1167
|
this.sleepNote = undefined;
|
|
1168
|
+
this.sleepAgent = undefined;
|
|
1169
|
+
if (this.sleepTimer) { clearTimeout(this.sleepTimer); this.sleepTimer = null; }
|
|
1146
1170
|
return wasAsleep;
|
|
1147
1171
|
}
|
|
1148
1172
|
|
|
1173
|
+
/**
|
|
1174
|
+
* Self-wake handler: fired by the sleepTimer when a sleep window elapses.
|
|
1175
|
+
* Clears the sleep state and requests an inference so the agent wakes on its
|
|
1176
|
+
* own — this is what makes `sleep(N)` a real "wake me in N seconds" rather
|
|
1177
|
+
* than just a suppression window. No-op if sleep was already cleared (via
|
|
1178
|
+
* `wake`) or re-armed to a later time by a fresh `sleep` call.
|
|
1179
|
+
*/
|
|
1180
|
+
private wakeFromSleep(reason: string): void {
|
|
1181
|
+
this.sleepTimer = null;
|
|
1182
|
+
if (this.sleepUntil === 0 || this.sleepUntil > this.now()) return;
|
|
1183
|
+
const note = this.sleepNote;
|
|
1184
|
+
const agent = this.sleepAgent;
|
|
1185
|
+
this.sleepUntil = 0;
|
|
1186
|
+
this.sleepNote = undefined;
|
|
1187
|
+
this.sleepAgent = undefined;
|
|
1188
|
+
this.emitTrace({
|
|
1189
|
+
type: 'gate:decision',
|
|
1190
|
+
eventType: 'sleep:expired',
|
|
1191
|
+
matchedPolicy: 'asleep',
|
|
1192
|
+
trigger: true,
|
|
1193
|
+
behavior: 'always',
|
|
1194
|
+
timestamp: this.now(),
|
|
1195
|
+
});
|
|
1196
|
+
const detail = note ? `${reason}: ${note}` : reason;
|
|
1197
|
+
const targets = agent ? [agent] : this.getAgentNamesFn();
|
|
1198
|
+
for (const a of targets) this.requestInferenceFn(a, detail, 'sleep');
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1149
1201
|
/** Current sleep state, or null if awake. */
|
|
1150
1202
|
getSleepState(): { until: number; remainingMs: number; note?: string; suppressed: number } | null {
|
|
1151
1203
|
const remainingMs = this.sleepUntil - this.now();
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,16 @@ export type { GateConfig, GateOptions, GatePolicy, GatePolicyMatch, GateBehavior
|
|
|
63
63
|
// MCPL channel registry (exposed for modules that need channel-level operations)
|
|
64
64
|
export { ChannelRegistry } from './mcpl/index.js';
|
|
65
65
|
|
|
66
|
+
// MCPL server config (exposed for hosts that manage servers at runtime via
|
|
67
|
+
// connectMcplServer / disconnectMcplServer / restartMcplServer)
|
|
68
|
+
export type { McplServerConfig } from './mcpl/index.js';
|
|
69
|
+
|
|
70
|
+
// MCPL client connection (exposed so external tooling — server playtests,
|
|
71
|
+
// health probes, protocol harnesses — can dial an MCPL server exactly the
|
|
72
|
+
// way the framework host does, over stdio or WebSocket)
|
|
73
|
+
export { McplServerConnection } from './mcpl/index.js';
|
|
74
|
+
export type { McplHostCapabilities } from './mcpl/index.js';
|
|
75
|
+
|
|
66
76
|
// Per-channel conversation routing
|
|
67
77
|
export { ConversationRouter, DEFAULT_CLOSURE_PROMPT } from './mcpl/index.js';
|
|
68
78
|
export type {
|
|
@@ -823,6 +823,25 @@ export class ChannelRegistry {
|
|
|
823
823
|
// Lifecycle
|
|
824
824
|
// ==========================================================================
|
|
825
825
|
|
|
826
|
+
/**
|
|
827
|
+
* Remove all channel state belonging to a single server. Called by the
|
|
828
|
+
* framework when an MCPL server is disconnected at runtime, so a dead
|
|
829
|
+
* server's channels don't linger and route speech into the void.
|
|
830
|
+
*/
|
|
831
|
+
removeServer(serverId: string): void {
|
|
832
|
+
for (const [key, entry] of this.channels) {
|
|
833
|
+
if (entry.serverId !== serverId) continue;
|
|
834
|
+
this.channels.delete(key);
|
|
835
|
+
this.stopTyping(entry.descriptor.id);
|
|
836
|
+
if (this.defaultPublishChannel === entry.descriptor.id) {
|
|
837
|
+
this.defaultPublishChannel = null;
|
|
838
|
+
this.defaultPublishMessageId = null;
|
|
839
|
+
this.defaultPublishThreadId = undefined;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
this.subscriptionPolicies.delete(serverId);
|
|
843
|
+
}
|
|
844
|
+
|
|
826
845
|
/**
|
|
827
846
|
* Stop all typing intervals and clear all channel registrations.
|
|
828
847
|
*/
|
|
@@ -1121,9 +1140,23 @@ export class ChannelRegistry {
|
|
|
1121
1140
|
* is no locus / the channel or its server can't be resolved (in which case
|
|
1122
1141
|
* the speech simply stays in chronicle + module surfaces).
|
|
1123
1142
|
*/
|
|
1143
|
+
/** Resolve the outbound locus (fork HOME → this-turn's TRIGGERING channel →
|
|
1144
|
+
* process-global default). Public so a multi-segment caller can snapshot it
|
|
1145
|
+
* ONCE and pin every segment to it via routeSpeech's `overrideChannelId`. */
|
|
1146
|
+
resolveLocus(conversationId: string): string | null {
|
|
1147
|
+
const home = this.homeChannelResolver?.(conversationId);
|
|
1148
|
+
return home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel ?? null;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1124
1151
|
async routeSpeech(
|
|
1125
1152
|
conversationId: string,
|
|
1126
1153
|
text: string,
|
|
1154
|
+
/** Pin the outbound locus, skipping resolution. Callers delivering MULTIPLE
|
|
1155
|
+
* segments of ONE turn snapshot resolveLocus() once and pass it here, so a
|
|
1156
|
+
* new turn starting mid-dispatch (segments run after the agent is idle,
|
|
1157
|
+
* PR #32) can't overwrite the per-agent triggering channel between segments
|
|
1158
|
+
* and split one reply across channels. */
|
|
1159
|
+
overrideChannelId?: string | null,
|
|
1127
1160
|
): Promise<{ delivered: boolean; channelId: string } | null> {
|
|
1128
1161
|
// Surface a routing failure: emit a trace AND notify the host (which drops
|
|
1129
1162
|
// a `[discord-send-failed]` marker into chronicle) so the agent learns her
|
|
@@ -1153,9 +1186,7 @@ export class ChannelRegistry {
|
|
|
1153
1186
|
// Using the global for a fork or a concurrent trunk turn is the item-3 bug:
|
|
1154
1187
|
// it tracks the most-recent inbound across ALL channels, so a reply lands
|
|
1155
1188
|
// wherever a message last happened to arrive rather than where it belongs.
|
|
1156
|
-
const
|
|
1157
|
-
const channelId =
|
|
1158
|
-
home ?? this.activeChannelResolver?.(conversationId) ?? this.defaultPublishChannel;
|
|
1189
|
+
const channelId = overrideChannelId ?? this.resolveLocus(conversationId);
|
|
1159
1190
|
if (!channelId) {
|
|
1160
1191
|
// Reached only when the agent has no home, no active triggering channel,
|
|
1161
1192
|
// AND no global inbound was ever seen.
|
package/src/mcpl/push-handler.ts
CHANGED
|
@@ -171,12 +171,16 @@ export class PushHandler {
|
|
|
171
171
|
const reason = err instanceof McplFeatureSetError
|
|
172
172
|
? err.message
|
|
173
173
|
: 'Feature set validation failed';
|
|
174
|
+
// Loud rejection — a rejected push event is an agent that silently
|
|
175
|
+
// never hears the message. (2026-07-09 diagnosability pass.)
|
|
176
|
+
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=${reason}`);
|
|
174
177
|
responder?.respond({ accepted: false, reason });
|
|
175
178
|
return;
|
|
176
179
|
}
|
|
177
180
|
|
|
178
181
|
// 2. Deduplicate by eventId
|
|
179
182
|
if (this.dedup.checkAndAdd(params.eventId)) {
|
|
183
|
+
console.error(`[push-event-rejected] server=${serverId} eventId=${params.eventId} reason=duplicate`);
|
|
180
184
|
responder?.respond({ accepted: false, reason: 'duplicate' });
|
|
181
185
|
return;
|
|
182
186
|
}
|
|
@@ -46,6 +46,13 @@ import { McplMethod } from './types.js';
|
|
|
46
46
|
* Spring Boot + JDA servers can take 5-10s to boot, so 30s is safe. */
|
|
47
47
|
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
48
48
|
|
|
49
|
+
/** Default per-request timeout in milliseconds (see McplServerConfig.requestTimeoutMs).
|
|
50
|
+
* A live-but-wedged server (accepts requests, never answers, never closes its
|
|
51
|
+
* transport) would otherwise freeze the awaiting agent turn forever — no
|
|
52
|
+
* endTurn, no further wakes, zero errors anywhere. 60s is generous for real
|
|
53
|
+
* tool work while still bounding the hang. */
|
|
54
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
55
|
+
|
|
49
56
|
/** MCP protocol version used in the initialize handshake. */
|
|
50
57
|
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
51
58
|
|
|
@@ -56,6 +63,8 @@ interface PendingRequest {
|
|
|
56
63
|
resolve: (result: unknown) => void;
|
|
57
64
|
reject: (error: Error) => void;
|
|
58
65
|
method: string;
|
|
66
|
+
/** Per-request timeout timer; cleared on response/close/timeout. */
|
|
67
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
/**
|
|
@@ -73,6 +82,9 @@ interface PendingRequest {
|
|
|
73
82
|
* - `'feature-sets-changed'` — Server sent `featureSets/changed`
|
|
74
83
|
* - `'error'` — Connection-level error
|
|
75
84
|
* - `'close'` — Connection closed
|
|
85
|
+
* - `'connect-failed'` — Initial connect failed; background retry scheduled
|
|
86
|
+
* - `'reconnect-failed'` — A background reconnect attempt failed; next retry scheduled
|
|
87
|
+
* - `'reconnect'` — Background reconnect succeeded
|
|
76
88
|
*/
|
|
77
89
|
export class McplServerConnection extends EventEmitter {
|
|
78
90
|
/** Unique server identifier from config. */
|
|
@@ -84,10 +96,20 @@ export class McplServerConnection extends EventEmitter {
|
|
|
84
96
|
/** The active transport (stdio child or WebSocket). Null for a disconnected
|
|
85
97
|
* stub awaiting its first background reconnect. */
|
|
86
98
|
private transport: McplTransport | null;
|
|
99
|
+
|
|
100
|
+
/** Whether the connection currently has a live transport. False for closed
|
|
101
|
+
* connections and disconnected reconnect-stubs awaiting a retry. */
|
|
102
|
+
get isConnected(): boolean {
|
|
103
|
+
return !this.closed && this.transport !== null;
|
|
104
|
+
}
|
|
105
|
+
|
|
87
106
|
private nextRequestId = 1;
|
|
88
107
|
private pendingRequests = new Map<string | number, PendingRequest>();
|
|
89
108
|
private closed = false;
|
|
90
109
|
|
|
110
|
+
/** Per-request timeout in ms (0 disables). See McplServerConfig.requestTimeoutMs. */
|
|
111
|
+
private requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS;
|
|
112
|
+
|
|
91
113
|
// Event buffering: events emitted before ready() are queued, not lost
|
|
92
114
|
private readyFlag = false;
|
|
93
115
|
private bufferedEvents: Array<{ event: string; args: unknown[] }> = [];
|
|
@@ -97,7 +119,17 @@ export class McplServerConnection extends EventEmitter {
|
|
|
97
119
|
private hostCapabilities: McplHostCapabilities | null = null;
|
|
98
120
|
private reconnectEnabled = false;
|
|
99
121
|
private reconnectIntervalMs = 5000;
|
|
122
|
+
private reconnectMaxIntervalMs = 300_000;
|
|
100
123
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
124
|
+
/** Consecutive failed connect attempts since the last successful handshake.
|
|
125
|
+
* Drives the exponential backoff and is reported on reconnect events. */
|
|
126
|
+
private reconnectAttempts = 0;
|
|
127
|
+
|
|
128
|
+
/** Whether a background reconnect loop will revive this connection after a
|
|
129
|
+
* drop. False once close() has been called (explicit close stops retrying). */
|
|
130
|
+
get willReconnect(): boolean {
|
|
131
|
+
return this.reconnectEnabled;
|
|
132
|
+
}
|
|
101
133
|
|
|
102
134
|
/**
|
|
103
135
|
* Private constructor. Use `McplServerConnection.connect()` instead.
|
|
@@ -167,6 +199,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
167
199
|
const { transport, capabilities } = await McplServerConnection.handshake(config, hostCapabilities);
|
|
168
200
|
|
|
169
201
|
const connection = new McplServerConnection(config.id, capabilities, transport);
|
|
202
|
+
connection.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
170
203
|
|
|
171
204
|
// Store config for reconnection
|
|
172
205
|
if (config.reconnect) {
|
|
@@ -174,6 +207,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
174
207
|
connection.hostCapabilities = hostCapabilities;
|
|
175
208
|
connection.reconnectEnabled = true;
|
|
176
209
|
connection.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
210
|
+
connection.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
177
211
|
}
|
|
178
212
|
|
|
179
213
|
return connection;
|
|
@@ -281,7 +315,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
281
315
|
|
|
282
316
|
// Non-blocking start: create a disconnected stub that will reconnect in background
|
|
283
317
|
console.error(`MCPL server "${config.id}" initial connect failed, will retry:`, (error as Error).message);
|
|
284
|
-
|
|
318
|
+
const stub = McplServerConnection.createDisconnectedStub(config, hostCapabilities);
|
|
319
|
+
// Surface the initial failure. The event is buffered by the emit()
|
|
320
|
+
// override until the host wires listeners and calls ready(), so it is
|
|
321
|
+
// not lost in the window before wireMcplEvents runs.
|
|
322
|
+
stub.emit('connect-failed', { error: (error as Error).message, attempt: 0 });
|
|
323
|
+
return stub;
|
|
285
324
|
}
|
|
286
325
|
}
|
|
287
326
|
|
|
@@ -297,10 +336,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
297
336
|
// Null transport — the stub is closed until its first background reconnect.
|
|
298
337
|
const stub = new McplServerConnection(config.id, null, null);
|
|
299
338
|
stub.closed = true;
|
|
339
|
+
stub.requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
300
340
|
stub.config = config;
|
|
301
341
|
stub.hostCapabilities = hostCapabilities;
|
|
302
342
|
stub.reconnectEnabled = true;
|
|
303
343
|
stub.reconnectIntervalMs = config.reconnectIntervalMs ?? 5000;
|
|
344
|
+
stub.reconnectMaxIntervalMs = config.reconnectMaxIntervalMs ?? 300_000;
|
|
345
|
+
stub.reconnectAttempts = 1; // the initial connect already failed
|
|
304
346
|
|
|
305
347
|
// Schedule background reconnect
|
|
306
348
|
stub.scheduleReconnect();
|
|
@@ -441,6 +483,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
441
483
|
|
|
442
484
|
// Reject all pending requests
|
|
443
485
|
for (const [id, pending] of this.pendingRequests) {
|
|
486
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
444
487
|
pending.reject(new Error(`Connection to MCPL server "${this.id}" closed while awaiting response for ${pending.method} (id=${id})`));
|
|
445
488
|
}
|
|
446
489
|
this.pendingRequests.clear();
|
|
@@ -457,14 +500,26 @@ export class McplServerConnection extends EventEmitter {
|
|
|
457
500
|
|
|
458
501
|
/**
|
|
459
502
|
* Schedule a background reconnection attempt.
|
|
503
|
+
*
|
|
504
|
+
* Delay grows exponentially with consecutive failures (base
|
|
505
|
+
* `reconnectIntervalMs`, doubling per failure, capped at
|
|
506
|
+
* `reconnectMaxIntervalMs`) with ±25% jitter so a fleet of servers that
|
|
507
|
+
* died together doesn't thundering-herd the same instant. The counter
|
|
508
|
+
* resets on a successful handshake, so a later disconnect starts over at
|
|
509
|
+
* the base interval.
|
|
460
510
|
*/
|
|
461
511
|
private scheduleReconnect(): void {
|
|
462
512
|
if (!this.reconnectEnabled || this.reconnectTimer) return;
|
|
463
513
|
|
|
514
|
+
const exponent = Math.max(0, Math.min(this.reconnectAttempts - 1, 30));
|
|
515
|
+
const uncapped = this.reconnectIntervalMs * 2 ** exponent;
|
|
516
|
+
const capped = Math.min(uncapped, this.reconnectMaxIntervalMs);
|
|
517
|
+
const delay = capped * (0.75 + Math.random() * 0.5);
|
|
518
|
+
|
|
464
519
|
this.reconnectTimer = setTimeout(() => {
|
|
465
520
|
this.reconnectTimer = null;
|
|
466
521
|
this.attemptReconnect();
|
|
467
|
-
},
|
|
522
|
+
}, delay);
|
|
468
523
|
}
|
|
469
524
|
|
|
470
525
|
/**
|
|
@@ -476,6 +531,10 @@ export class McplServerConnection extends EventEmitter {
|
|
|
476
531
|
private async attemptReconnect(): Promise<void> {
|
|
477
532
|
if (!this.reconnectEnabled || !this.config || !this.hostCapabilities) return;
|
|
478
533
|
|
|
534
|
+
// Ordinal of this attempt: 0 was the initial connect, so the Nth retry
|
|
535
|
+
// reports attempt N (reconnectAttempts counts failures so far).
|
|
536
|
+
const attempt = Math.max(1, this.reconnectAttempts);
|
|
537
|
+
|
|
479
538
|
try {
|
|
480
539
|
const { transport, capabilities } = await McplServerConnection.handshake(
|
|
481
540
|
this.config,
|
|
@@ -494,9 +553,12 @@ export class McplServerConnection extends EventEmitter {
|
|
|
494
553
|
this.readyFlag = true;
|
|
495
554
|
|
|
496
555
|
console.error(`MCPL server "${this.id}" reconnected successfully`);
|
|
497
|
-
this.
|
|
556
|
+
this.reconnectAttempts = 0;
|
|
557
|
+
this.emit('reconnect', { attempts: attempt });
|
|
498
558
|
} catch (error) {
|
|
499
559
|
console.error(`MCPL server "${this.id}" reconnect failed:`, (error as Error).message);
|
|
560
|
+
this.reconnectAttempts = attempt + 1;
|
|
561
|
+
this.emit('reconnect-failed', { error: (error as Error).message, attempt });
|
|
500
562
|
this.scheduleReconnect();
|
|
501
563
|
}
|
|
502
564
|
}
|
|
@@ -508,6 +570,13 @@ export class McplServerConnection extends EventEmitter {
|
|
|
508
570
|
/**
|
|
509
571
|
* Send a JSON-RPC request and return a promise that resolves with the result
|
|
510
572
|
* or rejects with a JSON-RPC error.
|
|
573
|
+
*
|
|
574
|
+
* Every request carries a timeout (default 60s, configurable via
|
|
575
|
+
* McplServerConfig.requestTimeoutMs, 0 disables): a live-but-stuck server
|
|
576
|
+
* that accepts a request and never answers rejects the pending promise with
|
|
577
|
+
* a descriptive error instead of freezing the caller forever. For tools/call
|
|
578
|
+
* the framework maps the rejection to an isError tool_result, so a hung tool
|
|
579
|
+
* surfaces as a normal tool error and the turn completes.
|
|
511
580
|
*/
|
|
512
581
|
private sendRequest(method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
513
582
|
if (this.closed) {
|
|
@@ -523,7 +592,23 @@ export class McplServerConnection extends EventEmitter {
|
|
|
523
592
|
};
|
|
524
593
|
|
|
525
594
|
return new Promise((resolve, reject) => {
|
|
526
|
-
|
|
595
|
+
const pending: PendingRequest = { resolve, reject, method };
|
|
596
|
+
if (this.requestTimeoutMs > 0) {
|
|
597
|
+
pending.timer = setTimeout(() => {
|
|
598
|
+
this.pendingRequests.delete(id);
|
|
599
|
+
reject(new Error(
|
|
600
|
+
`MCPL server "${this.id}" did not respond to ${method} (id=${id}) ` +
|
|
601
|
+
`within ${this.requestTimeoutMs}ms — the server may be hung. ` +
|
|
602
|
+
`The request was abandoned; the connection remains open. Note the ` +
|
|
603
|
+
`tool may still have completed server-side (this is only a response ` +
|
|
604
|
+
`timeout, not a cancellation) — verify state before retrying, as a ` +
|
|
605
|
+
`blind retry of a stateful/side-effecting tool may duplicate it.`,
|
|
606
|
+
));
|
|
607
|
+
}, this.requestTimeoutMs);
|
|
608
|
+
// Don't hold the event loop open for the watchdog alone.
|
|
609
|
+
pending.timer.unref?.();
|
|
610
|
+
}
|
|
611
|
+
this.pendingRequests.set(id, pending);
|
|
527
612
|
this.transport!.writeLine(JSON.stringify(request));
|
|
528
613
|
});
|
|
529
614
|
}
|
|
@@ -626,10 +711,30 @@ export class McplServerConnection extends EventEmitter {
|
|
|
626
711
|
private handleResponse(response: JsonRpcResponse): void {
|
|
627
712
|
const pending = this.pendingRequests.get(response.id);
|
|
628
713
|
if (!pending) {
|
|
714
|
+
// Orphaned response — the request already timed out (or was settled) and
|
|
715
|
+
// was removed from pendingRequests. Stateless late results are harmless to
|
|
716
|
+
// drop, but a late STATEFUL result carries a `state`/`checkpoint` the
|
|
717
|
+
// server has already advanced to: dropping it silently diverges the host's
|
|
718
|
+
// checkpoint tree from the server's, and every subsequent call then sends
|
|
719
|
+
// stale state. We can't safely re-inject it here (the dispatch context is
|
|
720
|
+
// gone), but we surface it so the divergence is greppable instead of
|
|
721
|
+
// invisible.
|
|
722
|
+
const result = response.result;
|
|
723
|
+
if (result && typeof result === 'object') {
|
|
724
|
+
const r = result as { state?: unknown; checkpoint?: unknown };
|
|
725
|
+
if (r.state !== undefined || r.checkpoint !== undefined) {
|
|
726
|
+
this.emit('orphaned-response', {
|
|
727
|
+
id: response.id,
|
|
728
|
+
hadState: r.state !== undefined,
|
|
729
|
+
hadCheckpoint: r.checkpoint !== undefined,
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
}
|
|
629
733
|
return; // Orphaned response — ignore
|
|
630
734
|
}
|
|
631
735
|
|
|
632
736
|
this.pendingRequests.delete(response.id);
|
|
737
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
633
738
|
|
|
634
739
|
if (response.error) {
|
|
635
740
|
pending.reject(
|
|
@@ -682,6 +787,7 @@ export class McplServerConnection extends EventEmitter {
|
|
|
682
787
|
|
|
683
788
|
// Reject all pending requests
|
|
684
789
|
for (const [id, pending] of this.pendingRequests) {
|
|
790
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
685
791
|
pending.reject(
|
|
686
792
|
new Error(
|
|
687
793
|
`MCPL server "${this.id}" disconnected unexpectedly (code=${info.code ?? 'n/a'}, signal=${info.signal ?? 'n/a'}, reason=${info.reason ?? 'unknown'}) while awaiting ${pending.method} (id=${id})`,
|
package/src/mcpl/types.ts
CHANGED
|
@@ -234,11 +234,29 @@ export interface McplServerConfig {
|
|
|
234
234
|
reconnect?: boolean;
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
|
-
*
|
|
237
|
+
* Base interval between reconnection attempts in milliseconds.
|
|
238
|
+
* The actual delay doubles with each consecutive failure (with ±25% jitter)
|
|
239
|
+
* up to `reconnectMaxIntervalMs`, and resets after a successful handshake.
|
|
238
240
|
* Default: 5000 (5 seconds).
|
|
239
241
|
*/
|
|
240
242
|
reconnectIntervalMs?: number;
|
|
241
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Ceiling for the exponential reconnect backoff in milliseconds.
|
|
246
|
+
* Default: 300000 (5 minutes).
|
|
247
|
+
*/
|
|
248
|
+
reconnectMaxIntervalMs?: number;
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Per-request timeout in milliseconds for outbound JSON-RPC requests
|
|
252
|
+
* (tools/call, tools/list, channels/*, hooks). A live-but-stuck server that
|
|
253
|
+
* accepts a request and never responds would otherwise freeze the awaiting
|
|
254
|
+
* agent turn forever; with the timeout the pending request rejects with a
|
|
255
|
+
* descriptive error, which the framework surfaces as a normal isError
|
|
256
|
+
* tool_result. Set 0 to disable. Default: 60000 (60 seconds).
|
|
257
|
+
*/
|
|
258
|
+
requestTimeoutMs?: number;
|
|
259
|
+
|
|
242
260
|
/**
|
|
243
261
|
* Optional callback to filter which incoming channel messages should trigger
|
|
244
262
|
* agent inference. Receives the text content and message metadata.
|
package/src/module-registry.ts
CHANGED
|
@@ -27,6 +27,25 @@ import type { Agent } from './agent.js';
|
|
|
27
27
|
|
|
28
28
|
const MODULE_STATE_PREFIX = 'modules/';
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* True when a registerState failure means "this state id already exists" —
|
|
32
|
+
* the one failure that is safe to swallow on an idempotent re-registration.
|
|
33
|
+
* Anything else (closed store, IO error) must propagate: swallowing it defers
|
|
34
|
+
* the real error to a later write with far worse context.
|
|
35
|
+
*/
|
|
36
|
+
export function isStateExistsError(err: unknown): boolean {
|
|
37
|
+
return err instanceof Error && err.message.includes('State already exists');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Hard ceiling for a single module's gatherContext budget, regardless of the
|
|
42
|
+
* `contextTimeoutMs` it declares. gatherContext runs on the critical path of
|
|
43
|
+
* every inference turn, so an unbounded self-declared budget would let one
|
|
44
|
+
* wedged module stall every agent. 30s is generous for legitimate work (e.g. a
|
|
45
|
+
* retrieval module's sequential LLM calls) while keeping the worst case bounded.
|
|
46
|
+
*/
|
|
47
|
+
const MAX_CONTEXT_TIMEOUT_MS = 30_000;
|
|
48
|
+
|
|
30
49
|
/**
|
|
31
50
|
* Registered speech handler.
|
|
32
51
|
*/
|
|
@@ -302,20 +321,44 @@ export class ModuleRegistry {
|
|
|
302
321
|
/**
|
|
303
322
|
* Gather context injections from all modules before inference.
|
|
304
323
|
* Calls each module's gatherContext() in parallel with a per-module timeout.
|
|
305
|
-
* Fail-open: timed-out or erroring modules are skipped
|
|
324
|
+
* Fail-open: timed-out or erroring modules are skipped (with a stderr line).
|
|
325
|
+
*
|
|
326
|
+
* The per-module budget is `module.contextTimeoutMs` when declared, else the
|
|
327
|
+
* `timeoutMs` default (15s). Modules whose gatherContext does real work —
|
|
328
|
+
* e.g. a retrieval module making sequential LLM calls with backoff — should
|
|
329
|
+
* declare a larger `contextTimeoutMs` rather than silently losing their
|
|
330
|
+
* injection every turn to the shared default. The declared value is clamped
|
|
331
|
+
* to MAX_CONTEXT_TIMEOUT_MS so no single module can hold every turn hostage.
|
|
306
332
|
* Adapted from Anarchid/agent-framework@mcpl-module-proto.
|
|
307
333
|
*/
|
|
308
|
-
async gatherContext(agentName: string, timeoutMs =
|
|
334
|
+
async gatherContext(agentName: string, timeoutMs = 15_000): Promise<ContextInjection[]> {
|
|
309
335
|
const injections: ContextInjection[] = [];
|
|
310
336
|
const promises: Promise<void>[] = [];
|
|
311
337
|
|
|
312
338
|
for (const module of this.modules.values()) {
|
|
313
339
|
if (!module.gatherContext) continue;
|
|
314
340
|
|
|
341
|
+
// Clamp the per-module budget. A module declaring `contextTimeoutMs`
|
|
342
|
+
// legitimately needs more than the shared default (e.g. a retrieval
|
|
343
|
+
// module doing sequential LLM calls), but it must not be able to hold
|
|
344
|
+
// EVERY inference turn of EVERY agent hostage: the fail-open contract is
|
|
345
|
+
// "a wedged module never blocks inference for long", so cap the wait.
|
|
346
|
+
const budgetMs = Math.min(
|
|
347
|
+
module.contextTimeoutMs ?? timeoutMs,
|
|
348
|
+
MAX_CONTEXT_TIMEOUT_MS,
|
|
349
|
+
);
|
|
350
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
315
351
|
const promise = Promise.race([
|
|
316
352
|
module.gatherContext(agentName).then(r => injections.push(...r)),
|
|
317
|
-
new Promise<void>((_, rej) =>
|
|
318
|
-
|
|
353
|
+
new Promise<void>((_, rej) => {
|
|
354
|
+
timer = setTimeout(
|
|
355
|
+
() => rej(new Error(`gatherContext timed out after ${budgetMs}ms`)),
|
|
356
|
+
budgetMs,
|
|
357
|
+
);
|
|
358
|
+
}),
|
|
359
|
+
])
|
|
360
|
+
.catch(err => console.error(`[${module.name}] gatherContext:`, err))
|
|
361
|
+
.finally(() => clearTimeout(timer));
|
|
319
362
|
|
|
320
363
|
promises.push(promise as Promise<void>);
|
|
321
364
|
}
|
|
@@ -435,6 +478,72 @@ class ModuleContextImpl implements ModuleContext {
|
|
|
435
478
|
this.store.setStateJson(this.stateId, fullState);
|
|
436
479
|
}
|
|
437
480
|
|
|
481
|
+
/** Log-state names this context has registered (registerLogState is
|
|
482
|
+
* contract-required in start(), so this is populated before any use). */
|
|
483
|
+
private registeredLogStates = new Set<string>();
|
|
484
|
+
|
|
485
|
+
private logStateId(name: string): string {
|
|
486
|
+
if (!name || name === 'state' || name.includes('/')) {
|
|
487
|
+
throw new Error(
|
|
488
|
+
`Invalid module log state name '${name}': must be non-empty, not 'state', and contain no '/'`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return `${MODULE_STATE_PREFIX}${this.moduleName}/${name}`;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Like logStateId, but only for names that went through registerLogState.
|
|
496
|
+
* Chronicle happily appends to an unregistered state — it just never gets a
|
|
497
|
+
* snapshot strategy, so every reconstruction replays the full op chain from
|
|
498
|
+
* record one. A forgotten registration or a typo'd name in one call site
|
|
499
|
+
* must be an immediate, attributable error, not a silent O(n²)-read trap.
|
|
500
|
+
*/
|
|
501
|
+
private registeredLogStateId(name: string): string {
|
|
502
|
+
if (!this.registeredLogStates.has(name)) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`Module log state '${name}' is not registered for module '${this.moduleName}' — ` +
|
|
505
|
+
`call registerLogState('${name}') in start() before using it`
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
return this.logStateId(name);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
registerLogState(
|
|
512
|
+
name: string,
|
|
513
|
+
opts?: { deltaSnapshotEvery?: number; fullSnapshotEvery?: number }
|
|
514
|
+
): void {
|
|
515
|
+
const id = this.logStateId(name);
|
|
516
|
+
try {
|
|
517
|
+
this.store.registerState({
|
|
518
|
+
id,
|
|
519
|
+
strategy: 'append_log',
|
|
520
|
+
deltaSnapshotEvery: opts?.deltaSnapshotEvery ?? 100,
|
|
521
|
+
fullSnapshotEvery: opts?.fullSnapshotEvery ?? 20,
|
|
522
|
+
});
|
|
523
|
+
} catch (err) {
|
|
524
|
+
if (!isStateExistsError(err)) throw err;
|
|
525
|
+
// Already registered (previous run) — idempotent path.
|
|
526
|
+
}
|
|
527
|
+
this.registeredLogStates.add(name);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
appendToLog<T>(name: string, item: T): void {
|
|
531
|
+
this.store.appendToStateJson(this.registeredLogStateId(name), item);
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
editLogItem<T>(name: string, index: number, item: T): void {
|
|
535
|
+
this.store.editStateItem(this.registeredLogStateId(name), index, Buffer.from(JSON.stringify(item)));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
getLog<T>(name: string): T[] {
|
|
539
|
+
const data = this.store.getStateJson(this.registeredLogStateId(name));
|
|
540
|
+
return Array.isArray(data) ? (data as T[]) : [];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
getLogLength(name: string): number {
|
|
544
|
+
return this.store.getStateLen(this.registeredLogStateId(name)) ?? 0;
|
|
545
|
+
}
|
|
546
|
+
|
|
438
547
|
getModule<T extends Module>(name: string): T | null {
|
|
439
548
|
return this.registry.getModule<T>(name);
|
|
440
549
|
}
|
package/src/types/agent.ts
CHANGED
|
@@ -45,6 +45,22 @@ export interface AgentConfig {
|
|
|
45
45
|
/** Per-agent context compile budget (input tokens). When unset, the
|
|
46
46
|
* ContextManager's built-in default (100k) applies. */
|
|
47
47
|
contextBudgetTokens?: number;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Prompt-cache TTL forwarded to the provider (Anthropic `cache_control.ttl`).
|
|
51
|
+
* '5m' (provider default) or '1h'. When unset, Membrane's default applies.
|
|
52
|
+
*
|
|
53
|
+
* Why you'd set '1h': for persistent agents whose conversational cadence is
|
|
54
|
+
* slower than 5 minutes, the default TTL expires between turns and the full
|
|
55
|
+
* context is re-WRITTEN to cache on nearly every call. Cache writes carry a
|
|
56
|
+
* premium (1.25x base input for 5m, 2x for 1h) while reads cost 0.1x — so a
|
|
57
|
+
* chatty-but-not-rapid agent pays the write premium over and over. With '1h'
|
|
58
|
+
* the write happens once per idle-hour and subsequent turns hit cache reads;
|
|
59
|
+
* in practice cache writes can dominate spend for slow-cadence agents.
|
|
60
|
+
* Keep '5m' (or unset) for high-frequency loops with sub-5-minute cadence,
|
|
61
|
+
* where the cheaper write premium wins.
|
|
62
|
+
*/
|
|
63
|
+
cacheTtl?: '5m' | '1h';
|
|
48
64
|
/**
|
|
49
65
|
* Extended thinking config. When `enabled: true`, the agent runs with
|
|
50
66
|
* Anthropic's native extended thinking; responses include `thinking` blocks
|
|
@@ -63,6 +79,28 @@ export interface AgentConfig {
|
|
|
63
79
|
*/
|
|
64
80
|
display?: 'summarized' | 'omitted';
|
|
65
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* How to handle a model content-policy refusal (`stop_reason: refusal`).
|
|
84
|
+
* When `autoRewind` is on, a refused turn triggers an automatic rewind: the
|
|
85
|
+
* framework redacts the triggering turn (the tool result or message that
|
|
86
|
+
* tripped the classifier), injects a metadata-only marker in its place (which
|
|
87
|
+
* carries none of the offending content, so it cannot itself re-trip), and
|
|
88
|
+
* re-runs — up to `maxRewinds` times before giving up. This keeps the agent
|
|
89
|
+
* on its own model (no fallback-model substitution) while self-healing around
|
|
90
|
+
* a poison turn. Default: off (a refusal just surfaces a marker + reaction).
|
|
91
|
+
*/
|
|
92
|
+
refusalHandling?: {
|
|
93
|
+
/** Auto-rewind the triggering turn on refusal and retry. Default false. */
|
|
94
|
+
autoRewind?: boolean;
|
|
95
|
+
/** Max consecutive rewinds before giving up a turn. Default 3. */
|
|
96
|
+
maxRewinds?: number;
|
|
97
|
+
/**
|
|
98
|
+
* When the rewound turn is a *human* message (not a machine tool result),
|
|
99
|
+
* announce the withholding on the conversational surface (Discord) rather
|
|
100
|
+
* than dropping it silently. Default true.
|
|
101
|
+
*/
|
|
102
|
+
announceHumanTurns?: boolean;
|
|
103
|
+
};
|
|
66
104
|
}
|
|
67
105
|
|
|
68
106
|
/**
|