@animalabs/agent-framework 0.5.6 → 0.5.7
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 +20 -4
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +45 -14
- package/dist/src/agent.js.map +1 -1
- package/dist/src/framework.d.ts +153 -5
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1033 -56
- package/dist/src/framework.js.map +1 -1
- package/dist/src/gate/event-gate.d.ts +27 -0
- package/dist/src/gate/event-gate.d.ts.map +1 -1
- package/dist/src/gate/event-gate.js +140 -1
- package/dist/src/gate/event-gate.js.map +1 -1
- package/dist/src/gate/types.d.ts +17 -0
- package/dist/src/gate/types.d.ts.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +50 -0
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +180 -0
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/conversation-router.d.ts +161 -0
- package/dist/src/mcpl/conversation-router.d.ts.map +1 -0
- package/dist/src/mcpl/conversation-router.js +179 -0
- package/dist/src/mcpl/conversation-router.js.map +1 -0
- package/dist/src/mcpl/feature-set-manager.d.ts.map +1 -1
- package/dist/src/mcpl/feature-set-manager.js +10 -1
- package/dist/src/mcpl/feature-set-manager.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +2 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +2 -0
- package/dist/src/mcpl/index.js.map +1 -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/module-registry.d.ts +3 -1
- package/dist/src/module-registry.d.ts.map +1 -1
- package/dist/src/module-registry.js +9 -2
- package/dist/src/module-registry.js.map +1 -1
- package/dist/src/modules/discord/index.d.ts +10 -0
- package/dist/src/modules/discord/index.d.ts.map +1 -1
- package/dist/src/modules/discord/index.js +83 -0
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/tool-result-history.d.ts +17 -0
- package/dist/src/tool-result-history.d.ts.map +1 -0
- package/dist/src/tool-result-history.js +60 -0
- package/dist/src/tool-result-history.js.map +1 -0
- package/dist/src/types/agent.d.ts +11 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +8 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/module.d.ts +15 -0
- package/dist/src/types/module.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +28 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.d.ts +2 -0
- package/dist/test/channel-registry-routing.test.d.ts.map +1 -0
- package/dist/test/channel-registry-routing.test.js +75 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -0
- package/dist/test/conversation-router.test.d.ts +2 -0
- package/dist/test/conversation-router.test.d.ts.map +1 -0
- package/dist/test/conversation-router.test.js +160 -0
- package/dist/test/conversation-router.test.js.map +1 -0
- package/dist/test/conversation-routing.test.d.ts +7 -0
- package/dist/test/conversation-routing.test.d.ts.map +1 -0
- package/dist/test/conversation-routing.test.js +203 -0
- package/dist/test/conversation-routing.test.js.map +1 -0
- package/dist/test/framework.test.js +73 -1
- package/dist/test/framework.test.js.map +1 -1
- package/dist/test/helpers/mock-membrane.d.ts +41 -0
- package/dist/test/helpers/mock-membrane.d.ts.map +1 -0
- package/dist/test/helpers/mock-membrane.js +161 -0
- package/dist/test/helpers/mock-membrane.js.map +1 -0
- package/dist/test/inference-failure-observability.test.d.ts +2 -0
- package/dist/test/inference-failure-observability.test.d.ts.map +1 -0
- package/dist/test/inference-failure-observability.test.js +75 -0
- package/dist/test/inference-failure-observability.test.js.map +1 -0
- package/dist/test/tool-result-history.test.d.ts +7 -0
- package/dist/test/tool-result-history.test.d.ts.map +1 -0
- package/dist/test/tool-result-history.test.js +77 -0
- package/dist/test/tool-result-history.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +53 -18
- package/src/framework.ts +1188 -59
- package/src/gate/event-gate.ts +144 -1
- package/src/gate/types.ts +17 -0
- package/src/index.ts +12 -0
- package/src/mcpl/channel-registry.ts +221 -0
- package/src/mcpl/conversation-router.ts +284 -0
- package/src/mcpl/feature-set-manager.ts +14 -1
- package/src/mcpl/index.ts +12 -0
- package/src/mcpl/server-connection.ts +4 -0
- package/src/module-registry.ts +14 -2
- package/src/modules/discord/index.ts +93 -0
- package/src/tool-result-history.ts +57 -0
- package/src/types/agent.ts +11 -0
- package/src/types/framework.ts +9 -0
- package/src/types/module.ts +16 -0
- package/src/types/trace.ts +36 -0
package/src/framework.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { JsStore } from '@animalabs/chronicle';
|
|
3
|
-
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult } from '@animalabs/membrane';
|
|
3
|
+
import type { Membrane, ContentBlock, NormalizedRequest, YieldingStream, ToolResult as MembraneToolResult, ToolResultContentBlock } from '@animalabs/membrane';
|
|
4
4
|
import { MembraneError } from '@animalabs/membrane';
|
|
5
5
|
import { ContextManager, PassthroughStrategy } from '@animalabs/context-manager';
|
|
6
6
|
import type {
|
|
@@ -50,7 +50,9 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
|
|
|
50
50
|
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
|
|
51
51
|
import { InferenceRouter } from './mcpl/inference-router.js';
|
|
52
52
|
import { ChannelRegistry } from './mcpl/channel-registry.js';
|
|
53
|
+
import { ConversationRouter } from './mcpl/conversation-router.js';
|
|
53
54
|
import { safeSlice } from './safe-slice.js';
|
|
55
|
+
import { toolResultDataToHistoryString } from './tool-result-history.js';
|
|
54
56
|
import { CheckpointManager } from './mcpl/checkpoint-manager.js';
|
|
55
57
|
import { isToolAllowed } from './mcpl/tool-policy.js';
|
|
56
58
|
import { EventGate } from './gate/event-gate.js';
|
|
@@ -74,6 +76,7 @@ import type {
|
|
|
74
76
|
import type { ContextInjection } from '@animalabs/context-manager';
|
|
75
77
|
|
|
76
78
|
const FRAMEWORK_STATE_ID = 'framework/state';
|
|
79
|
+
const CONVERSATION_ROUTER_STATE_ID = 'framework/conversation-router';
|
|
77
80
|
const INFERENCE_LOG_ID = 'framework/inference-log';
|
|
78
81
|
const PROCESS_LOG_ID = 'framework/process-log';
|
|
79
82
|
const TURN_CHECKPOINTS_ID = 'framework/turn-checkpoints';
|
|
@@ -180,6 +183,24 @@ function extractGateFields(event: ProcessEvent): {
|
|
|
180
183
|
/**
|
|
181
184
|
* The main agent framework.
|
|
182
185
|
*/
|
|
186
|
+
/** Params for a `host/command` request from an MCPL surface server. */
|
|
187
|
+
interface HostCommandParams {
|
|
188
|
+
command?: string;
|
|
189
|
+
agentName?: string;
|
|
190
|
+
turns?: number;
|
|
191
|
+
/** Message-granular undo: branch the chronicle so the last N messages
|
|
192
|
+
* (regardless of participant) are no longer on the active branch.
|
|
193
|
+
* Mutually exclusive with `turns`. */
|
|
194
|
+
messages?: number;
|
|
195
|
+
/** For the `hide` command: Discord message id of the (first) message to
|
|
196
|
+
* remove. With `toMessageId`, removes the inclusive range between them. */
|
|
197
|
+
fromMessageId?: string;
|
|
198
|
+
/** For the `hide` command: Discord message id ending an inclusive range. */
|
|
199
|
+
toMessageId?: string;
|
|
200
|
+
requesterId?: string;
|
|
201
|
+
requesterName?: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
183
204
|
export class AgentFramework {
|
|
184
205
|
private store: JsStore;
|
|
185
206
|
private ownsStore: boolean;
|
|
@@ -199,6 +220,12 @@ export class AgentFramework {
|
|
|
199
220
|
private processLoggingBroadcast: boolean;
|
|
200
221
|
private activeStreams: Map<string, Promise<void>> = new Map();
|
|
201
222
|
private pendingAssistantBlocks: Map<string, ContentBlock[]> = new Map();
|
|
223
|
+
/** Per-agent count of consecutive exhausted inferences (reset on any success).
|
|
224
|
+
* Drives hard-down escalation — see noteInferenceExhausted. */
|
|
225
|
+
private consecutiveInferenceFailures: Map<string, number> = new Map();
|
|
226
|
+
/** N consecutive failed inferences ⇒ the agent is treated as hard-down and
|
|
227
|
+
* escalated loudly to stderr. */
|
|
228
|
+
private readonly inferenceFailureEscalationThreshold = 3;
|
|
202
229
|
/** Name of the primary (non-ephemeral) agent for routing framework messages. */
|
|
203
230
|
private primaryAgentName: string | null = null;
|
|
204
231
|
|
|
@@ -219,6 +246,22 @@ export class AgentFramework {
|
|
|
219
246
|
private inferenceRouter: InferenceRouter | null = null;
|
|
220
247
|
private channelRegistry: ChannelRegistry | null = null;
|
|
221
248
|
private checkpointManager: CheckpointManager | null = null;
|
|
249
|
+
/** Per-channel conversation routing (null unless config.conversations set). */
|
|
250
|
+
private conversationRouter: ConversationRouter | null = null;
|
|
251
|
+
/** Agent configs by name — fork agents are built from the template's config. */
|
|
252
|
+
private agentConfigs: Map<string, AgentConfig> = new Map();
|
|
253
|
+
/**
|
|
254
|
+
* Fork agent → its home channel. Permanent (unlike router bindings, which
|
|
255
|
+
* expire): publish/injection scoping must survive unbinding so the closure
|
|
256
|
+
* turn still lands in the right channel.
|
|
257
|
+
*/
|
|
258
|
+
private conversationAgentHomes: Map<string, string> = new Map();
|
|
259
|
+
/** Last idle-TTL sweep timestamp. */
|
|
260
|
+
private lastConversationSweep = 0;
|
|
261
|
+
|
|
262
|
+
/** Forks whose TTL closure turn has been queued — disposed (removed from
|
|
263
|
+
* agents/agentConfigs/conversationAgentHomes) when their stream ends. */
|
|
264
|
+
private closingConversationAgents: Set<string> = new Set();
|
|
222
265
|
private mcplTools: import('./types/index.js').ToolDefinition[] = [];
|
|
223
266
|
private mcplToolRefreshInFlight = false;
|
|
224
267
|
private mcplToolRefreshPending = false;
|
|
@@ -265,6 +308,7 @@ export class AgentFramework {
|
|
|
265
308
|
getMessage: (id) => this.getMessage(id),
|
|
266
309
|
queryMessages: (filter) => this.queryMessages(filter),
|
|
267
310
|
pushEvent: (event) => this.pushEvent(event),
|
|
311
|
+
onTrace: (listener) => this.onTrace(listener),
|
|
268
312
|
});
|
|
269
313
|
}
|
|
270
314
|
|
|
@@ -352,6 +396,35 @@ export class AgentFramework {
|
|
|
352
396
|
await framework.addModule(module);
|
|
353
397
|
}
|
|
354
398
|
|
|
399
|
+
// Initialize per-channel conversation routing (if configured)
|
|
400
|
+
if (config.conversations) {
|
|
401
|
+
if (!framework.agents.has(config.conversations.templateAgent)) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`conversations.templateAgent "${config.conversations.templateAgent}" ` +
|
|
404
|
+
`is not a configured agent`
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
framework.conversationRouter = new ConversationRouter(config.conversations);
|
|
408
|
+
|
|
409
|
+
// Generation counters persist across restarts — reusing generation 1's
|
|
410
|
+
// agent name after a restart would reopen (and re-seed) the previous
|
|
411
|
+
// engagement's Chronicle namespace.
|
|
412
|
+
try {
|
|
413
|
+
store.registerState({ id: CONVERSATION_ROUTER_STATE_ID, strategy: 'snapshot' });
|
|
414
|
+
} catch {
|
|
415
|
+
// Already registered
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
const data = store.getStateJson(CONVERSATION_ROUTER_STATE_ID) as
|
|
419
|
+
{ generations?: Record<string, number> } | null;
|
|
420
|
+
if (data?.generations) {
|
|
421
|
+
framework.conversationRouter.hydrateGenerations(data.generations);
|
|
422
|
+
}
|
|
423
|
+
} catch {
|
|
424
|
+
// No persisted state yet
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
355
428
|
// Initialize EventGate if configured (before MCPL so it can be wired as trigger filter)
|
|
356
429
|
if (config.gate) {
|
|
357
430
|
const configPath = config.gate.configPath
|
|
@@ -361,6 +434,7 @@ export class AgentFramework {
|
|
|
361
434
|
framework.eventGate = new EventGate({
|
|
362
435
|
configPath,
|
|
363
436
|
initialConfig: config.gate.config,
|
|
437
|
+
privilegedUsersPath: config.gate.privilegedUsersPath,
|
|
364
438
|
emitTrace: (e) => framework.emitTrace(e as { type: TraceEvent['type']; [key: string]: unknown }),
|
|
365
439
|
addMessage: (p, c, m) => framework.addMessage(p, c, m as MessageMetadata),
|
|
366
440
|
requestInference: (agentName, reason, source) => {
|
|
@@ -486,8 +560,15 @@ export class AgentFramework {
|
|
|
486
560
|
/**
|
|
487
561
|
* Add a trace event listener for observability.
|
|
488
562
|
*/
|
|
489
|
-
onTrace(listener: TraceEventListener): void {
|
|
563
|
+
onTrace(listener: TraceEventListener): () => void {
|
|
490
564
|
this.traceListeners.push(listener);
|
|
565
|
+
// Return an unsubscribe so callers with a bounded lifetime (e.g. modules
|
|
566
|
+
// that get torn down and recreated on session switch) don't leak
|
|
567
|
+
// listeners. Existing callers that ignore the return value are unaffected.
|
|
568
|
+
return () => {
|
|
569
|
+
const idx = this.traceListeners.indexOf(listener);
|
|
570
|
+
if (idx >= 0) this.traceListeners.splice(idx, 1);
|
|
571
|
+
};
|
|
491
572
|
}
|
|
492
573
|
|
|
493
574
|
/**
|
|
@@ -540,6 +621,14 @@ export class AgentFramework {
|
|
|
540
621
|
return Array.from(this.agents.values());
|
|
541
622
|
}
|
|
542
623
|
|
|
624
|
+
/**
|
|
625
|
+
* Get the per-channel conversation router (null unless config.conversations
|
|
626
|
+
* was set). Exposed for modules/UIs that surface active engagements.
|
|
627
|
+
*/
|
|
628
|
+
getConversationRouter(): ConversationRouter | null {
|
|
629
|
+
return this.conversationRouter;
|
|
630
|
+
}
|
|
631
|
+
|
|
543
632
|
/**
|
|
544
633
|
* Abort an in-flight inference for an agent.
|
|
545
634
|
*/
|
|
@@ -568,13 +657,88 @@ export class AgentFramework {
|
|
|
568
657
|
getAllTools(): import('./types/index.js').ToolDefinition[] {
|
|
569
658
|
const moduleTools = this.moduleRegistry.getAllTools();
|
|
570
659
|
const channelTools = this.channelRegistry?.getChannelTools() ?? [];
|
|
571
|
-
const gateTools = this.eventGate
|
|
660
|
+
const gateTools = this.eventGate
|
|
661
|
+
? [this.eventGate.getToolDefinition(), ...AgentFramework.SLEEP_TOOLS]
|
|
662
|
+
: [];
|
|
572
663
|
if (this.mcplTools.length === 0 && channelTools.length === 0 && gateTools.length === 0) {
|
|
573
664
|
return moduleTools;
|
|
574
665
|
}
|
|
575
666
|
return [...moduleTools, ...this.mcplTools, ...channelTools, ...gateTools];
|
|
576
667
|
}
|
|
577
668
|
|
|
669
|
+
/**
|
|
670
|
+
* Build the membrane-normalized request that WOULD be emitted if `agentName`
|
|
671
|
+
* were activated right now — WITHOUT running inference, opening a stream, or
|
|
672
|
+
* mutating agent state. Intended for debug/preview tooling.
|
|
673
|
+
*
|
|
674
|
+
* Transparency contract (default): the preview is side-effect-free and
|
|
675
|
+
* leaves no trace on the system. It does only read-only work —
|
|
676
|
+
* `ContextManager.compile` (which never triggers compression itself;
|
|
677
|
+
* compression runs in the background out-of-band), tool filtering, and
|
|
678
|
+
* system-prompt assembly — then delegates to `Agent.buildActivationRequest`.
|
|
679
|
+
* No tokens are spent, nothing is written to Chronicle, and no external
|
|
680
|
+
* MCPL server is contacted.
|
|
681
|
+
*
|
|
682
|
+
* The trade-off is fidelity: the dynamically-gathered ContextInjection[]
|
|
683
|
+
* (module `gatherContext` + MCPL `beforeInference` hooks) are NOT included
|
|
684
|
+
* by default, because gathering them is not transparent —
|
|
685
|
+
* - module `gatherContext` can run inference (e.g. RetrievalModule makes
|
|
686
|
+
* Haiku calls — real token cost and latency), and
|
|
687
|
+
* - MCPL `beforeInference` hooks are arbitrary RPCs to external servers
|
|
688
|
+
* with side effects, and a preview never sends the paired
|
|
689
|
+
* `afterInference`, which can leave a stateful server half-open.
|
|
690
|
+
*
|
|
691
|
+
* Pass `{ injections: true }` to opt into full-fidelity gathering and accept
|
|
692
|
+
* those side effects (byte-faithful to a real activation's injected context).
|
|
693
|
+
*/
|
|
694
|
+
async previewActivation(
|
|
695
|
+
agentName: string,
|
|
696
|
+
opts?: { injections?: boolean }
|
|
697
|
+
): Promise<NormalizedRequest> {
|
|
698
|
+
const agent = this.agents.get(agentName);
|
|
699
|
+
if (!agent) {
|
|
700
|
+
throw new Error(`Agent not found: ${agentName}`);
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
const tools = this.getAllTools().filter((t) => agent.canUseTool(t.name));
|
|
704
|
+
|
|
705
|
+
// Default: no dynamic injection gathering → fully transparent (no
|
|
706
|
+
// inference, no Chronicle writes, no external RPC). Opt in explicitly.
|
|
707
|
+
if (!opts?.injections) {
|
|
708
|
+
return agent.buildActivationRequest(tools, undefined);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// Full-fidelity path: mirrors startAgentStream's injection gathering.
|
|
712
|
+
// NOT transparent — see the doc comment above.
|
|
713
|
+
let injections: ContextInjection[] | undefined;
|
|
714
|
+
|
|
715
|
+
// Module gatherContext (fail-open, matches startAgentStream)
|
|
716
|
+
try {
|
|
717
|
+
const moduleInjections = await this.moduleRegistry.gatherContext(agentName);
|
|
718
|
+
if (moduleInjections.length > 0) {
|
|
719
|
+
injections = moduleInjections;
|
|
720
|
+
}
|
|
721
|
+
} catch (error) {
|
|
722
|
+
console.error('Module gatherContext error (preview):', error);
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
// MCPL beforeInference hooks (fail-open). Note: the paired afterInference
|
|
726
|
+
// is intentionally never sent here — this is a preview, not a real turn.
|
|
727
|
+
if (this.hookOrchestrator) {
|
|
728
|
+
try {
|
|
729
|
+
const hookParams = this.buildBeforeInferenceParams(agent);
|
|
730
|
+
const hookInjections = await this.hookOrchestrator.beforeInference(hookParams);
|
|
731
|
+
if (hookInjections.length > 0) {
|
|
732
|
+
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
733
|
+
}
|
|
734
|
+
} catch (error) {
|
|
735
|
+
console.error('beforeInference hook error (preview):', error);
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return agent.buildActivationRequest(tools, injections);
|
|
740
|
+
}
|
|
741
|
+
|
|
578
742
|
/**
|
|
579
743
|
* Check if process logging is enabled.
|
|
580
744
|
*/
|
|
@@ -840,6 +1004,313 @@ export class AgentFramework {
|
|
|
840
1004
|
* Query inference logs.
|
|
841
1005
|
* Returns entries with summary info (doesn't resolve blobs).
|
|
842
1006
|
*/
|
|
1007
|
+
/** Synthesized sleep/wake tool definitions (present when a gate is wired). */
|
|
1008
|
+
private static readonly SLEEP_TOOLS: import('./types/index.js').ToolDefinition[] = [
|
|
1009
|
+
{
|
|
1010
|
+
name: 'sleep',
|
|
1011
|
+
description:
|
|
1012
|
+
'Go quiet: suppress external pings and wakes for a number of seconds. ' +
|
|
1013
|
+
'Messages still accumulate in your context — you just won’t be woken to ' +
|
|
1014
|
+
'respond to them until the window passes. Your heartbeat still beats: you’ll ' +
|
|
1015
|
+
'briefly rouse on each tick and can keep resting or call `wake` to get up early. ' +
|
|
1016
|
+
'Privileged users can also still reach you. By default announces in your current channel.',
|
|
1017
|
+
inputSchema: {
|
|
1018
|
+
type: 'object',
|
|
1019
|
+
properties: {
|
|
1020
|
+
seconds: { type: 'number', description: 'How long to stay asleep, in seconds.' },
|
|
1021
|
+
announce: {
|
|
1022
|
+
type: 'boolean',
|
|
1023
|
+
description: 'Announce the sleep in the current sticky channel (default true).',
|
|
1024
|
+
},
|
|
1025
|
+
message: {
|
|
1026
|
+
type: 'string',
|
|
1027
|
+
description: 'Optional custom announcement text (overrides the default).',
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
required: ['seconds'],
|
|
1031
|
+
},
|
|
1032
|
+
},
|
|
1033
|
+
{
|
|
1034
|
+
name: 'wake',
|
|
1035
|
+
description: 'End your current sleep early, resuming normal wakes immediately.',
|
|
1036
|
+
inputSchema: { type: 'object' },
|
|
1037
|
+
},
|
|
1038
|
+
];
|
|
1039
|
+
|
|
1040
|
+
/** Refusal category → Discord reaction emoji. Unknown categories get 🛑. */
|
|
1041
|
+
private static readonly REFUSAL_REACTIONS: Record<string, string> = {
|
|
1042
|
+
bio: '☣️',
|
|
1043
|
+
chem: '🧪',
|
|
1044
|
+
nuclear: '☢️',
|
|
1045
|
+
cyber: '💻',
|
|
1046
|
+
reasoning_extraction: '🧠',
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
/**
|
|
1050
|
+
* Mark an inference refusal visibly: react on the message that holds the
|
|
1051
|
+
* conversational locus (the most recent incoming channel message) with a
|
|
1052
|
+
* category-specific emoji. Best-effort — failures are logged, never thrown,
|
|
1053
|
+
* and non-Discord loci are silently skipped.
|
|
1054
|
+
*/
|
|
1055
|
+
private async reactToRefusal(agentName: string, category: string): Promise<void> {
|
|
1056
|
+
try {
|
|
1057
|
+
const incoming = this.channelRegistry?.buildChannelContext()?.incoming;
|
|
1058
|
+
if (!incoming) return;
|
|
1059
|
+
// incoming.channelId is the MCPL composite id ("discord:<guild>:<channel>");
|
|
1060
|
+
// the reaction tool wants the raw Discord channel (or thread) id — the
|
|
1061
|
+
// last segment.
|
|
1062
|
+
const parts = incoming.channelId.split(':');
|
|
1063
|
+
if (parts[0] !== 'discord') return;
|
|
1064
|
+
const channelId = parts[parts.length - 1];
|
|
1065
|
+
const emoji = AgentFramework.REFUSAL_REACTIONS[category] ?? '🛑';
|
|
1066
|
+
// Resolve the MCPL server that owns the locus channel and call
|
|
1067
|
+
// tools/call directly on its connection (bare tool name — no prefix
|
|
1068
|
+
// games), bypassing the agent event queue so no synthetic tool-result
|
|
1069
|
+
// is injected into a turn the agent never took.
|
|
1070
|
+
const serverId = this.channelRegistry?.getChannelServerId(incoming.channelId);
|
|
1071
|
+
const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
|
|
1072
|
+
if (!server) {
|
|
1073
|
+
console.error(
|
|
1074
|
+
`[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`,
|
|
1075
|
+
);
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
await server.sendToolsCall('add_reaction', {
|
|
1079
|
+
channelId,
|
|
1080
|
+
messageId: incoming.messageId,
|
|
1081
|
+
emoji,
|
|
1082
|
+
});
|
|
1083
|
+
} catch (err) {
|
|
1084
|
+
console.error(
|
|
1085
|
+
'[inference-refusal] reaction failed:',
|
|
1086
|
+
err instanceof Error ? err.message : err,
|
|
1087
|
+
);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* Handle a `host/command` request from an MCPL surface server (e.g. a
|
|
1093
|
+
* Discord slash command). Currently supports:
|
|
1094
|
+
*
|
|
1095
|
+
* undo — revert the last N inference turns (branch-based, see
|
|
1096
|
+
* `undoLastTurn`). The response includes the last message the agent
|
|
1097
|
+
* would see in its context after the undo, obtained via the transparent
|
|
1098
|
+
* `previewActivation` render (no inference, no Chronicle writes).
|
|
1099
|
+
*/
|
|
1100
|
+
private async handleHostCommand(
|
|
1101
|
+
serverId: string,
|
|
1102
|
+
params: HostCommandParams,
|
|
1103
|
+
): Promise<{
|
|
1104
|
+
ok: boolean;
|
|
1105
|
+
error?: string;
|
|
1106
|
+
undone?: number;
|
|
1107
|
+
requested?: number;
|
|
1108
|
+
messagesRemoved?: number;
|
|
1109
|
+
hidden?: number;
|
|
1110
|
+
/** For `hide`: the Discord (channelId, messageId) of each removed message
|
|
1111
|
+
* that carried one — so the surface can mark them with a reaction. */
|
|
1112
|
+
hiddenRefs?: Array<{ channelId: string; messageId: string }>;
|
|
1113
|
+
lastVisible?: { participant?: string; role?: string; preview?: string } | null;
|
|
1114
|
+
}> {
|
|
1115
|
+
if (params.command !== 'undo' && params.command !== 'hide') {
|
|
1116
|
+
return { ok: false, error: `Unknown host command: ${String(params.command)}` };
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const agentName = params.agentName ?? [...this.agents.keys()][0];
|
|
1120
|
+
if (!agentName || !this.agents.has(agentName)) {
|
|
1121
|
+
return { ok: false, error: `Unknown agent: ${String(agentName)}` };
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// hide: redact a single message (or an inclusive range) from the active
|
|
1125
|
+
// branch, addressed by Discord message id. Unlike undo this is a
|
|
1126
|
+
// removal-in-place (chronicle redact), not a branch rewind.
|
|
1127
|
+
if (params.command === 'hide') {
|
|
1128
|
+
const agent = this.agents.get(agentName)!;
|
|
1129
|
+
if (agent.state.status !== 'idle') {
|
|
1130
|
+
return { ok: false, error: `Cannot hide while agent is ${agent.state.status}` };
|
|
1131
|
+
}
|
|
1132
|
+
if (!params.fromMessageId) {
|
|
1133
|
+
return { ok: false, error: 'hide: fromMessageId is required' };
|
|
1134
|
+
}
|
|
1135
|
+
const cm = agent.getContextManager();
|
|
1136
|
+
const all = cm.getAllMessages();
|
|
1137
|
+
const byDiscordId = (did: string) =>
|
|
1138
|
+
all.findIndex((m) => String((m.metadata as { messageId?: unknown } | undefined)?.messageId) === String(did));
|
|
1139
|
+
|
|
1140
|
+
// Collect Discord refs of every message in [lo, hi] that carries one,
|
|
1141
|
+
// so the surface can mark them. channelId here is whatever the ingest
|
|
1142
|
+
// path stored (raw id or "discord:guild:channel"); the surface
|
|
1143
|
+
// normalizes it.
|
|
1144
|
+
const refsIn = (lo: number, hi: number) => {
|
|
1145
|
+
const refs: Array<{ channelId: string; messageId: string }> = [];
|
|
1146
|
+
for (let i = lo; i <= hi; i++) {
|
|
1147
|
+
const md = all[i].metadata as { messageId?: unknown; channelId?: unknown } | undefined;
|
|
1148
|
+
if (md?.messageId && md?.channelId) {
|
|
1149
|
+
refs.push({ channelId: String(md.channelId), messageId: String(md.messageId) });
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return refs;
|
|
1153
|
+
};
|
|
1154
|
+
|
|
1155
|
+
const fromIdx = byDiscordId(params.fromMessageId);
|
|
1156
|
+
if (fromIdx < 0) {
|
|
1157
|
+
return {
|
|
1158
|
+
ok: false,
|
|
1159
|
+
error: `Message ${params.fromMessageId} is not an addressable message in context (it may only exist inside backscroll text, or predates this session).`,
|
|
1160
|
+
};
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
try {
|
|
1164
|
+
if (params.toMessageId) {
|
|
1165
|
+
const toIdx = byDiscordId(params.toMessageId);
|
|
1166
|
+
if (toIdx < 0) {
|
|
1167
|
+
return { ok: false, error: `Message ${params.toMessageId} is not an addressable message in context.` };
|
|
1168
|
+
}
|
|
1169
|
+
const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
|
1170
|
+
const refs = refsIn(lo, hi);
|
|
1171
|
+
cm.removeMessages(all[lo].id, all[hi].id);
|
|
1172
|
+
console.error(
|
|
1173
|
+
`[host-command] hide agent=${agentName} range removed=${hi - lo + 1} ` +
|
|
1174
|
+
`(${params.fromMessageId}..${params.toMessageId}) by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
1175
|
+
);
|
|
1176
|
+
return {
|
|
1177
|
+
ok: true,
|
|
1178
|
+
hidden: hi - lo + 1,
|
|
1179
|
+
hiddenRefs: refs,
|
|
1180
|
+
lastVisible: await this.lastVisiblePreview(agentName),
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
const refs = refsIn(fromIdx, fromIdx);
|
|
1184
|
+
cm.removeMessage(all[fromIdx].id);
|
|
1185
|
+
console.error(
|
|
1186
|
+
`[host-command] hide agent=${agentName} removed=1 (${params.fromMessageId}) ` +
|
|
1187
|
+
`by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
1188
|
+
);
|
|
1189
|
+
return {
|
|
1190
|
+
ok: true,
|
|
1191
|
+
hidden: 1,
|
|
1192
|
+
hiddenRefs: refs,
|
|
1193
|
+
lastVisible: await this.lastVisiblePreview(agentName),
|
|
1194
|
+
};
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Message-granular mode: branch the chronicle at the message that should
|
|
1201
|
+
// become the new tail (ContextManager.branchAt — origin-sequence-based
|
|
1202
|
+
// time-travel branching; see chronicle's rfc-state-item-origins addendum).
|
|
1203
|
+
// No turn checkpoints involved, so this reaches past turn boundaries.
|
|
1204
|
+
if (typeof params.messages === 'number' && params.messages > 0) {
|
|
1205
|
+
const agent = this.agents.get(agentName)!;
|
|
1206
|
+
if (agent.state.status !== 'idle') {
|
|
1207
|
+
return { ok: false, error: `Cannot undo while agent is ${agent.state.status}` };
|
|
1208
|
+
}
|
|
1209
|
+
const n = Math.max(1, Math.min(50, Math.floor(params.messages)));
|
|
1210
|
+
const cm = agent.getContextManager();
|
|
1211
|
+
const allMessages = cm.getAllMessages();
|
|
1212
|
+
if (n >= allMessages.length) {
|
|
1213
|
+
return {
|
|
1214
|
+
ok: false,
|
|
1215
|
+
error: `Cannot remove ${n} message(s) — history has ${allMessages.length}; at least one must remain.`,
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
const target = allMessages[allMessages.length - 1 - n];
|
|
1219
|
+
const branchName = cm.branchAt(target.id, `undo-msgs/${agentName}/${Date.now()}`);
|
|
1220
|
+
await cm.switchBranch(branchName);
|
|
1221
|
+
|
|
1222
|
+
// Materialize config files from the new branch (fire-and-forget; gate
|
|
1223
|
+
// picks up via mtime) — mirrors undoLastTurn.
|
|
1224
|
+
const wsUndo = this.moduleRegistry.getModule('workspace');
|
|
1225
|
+
if (wsUndo && 'materializeMount' in wsUndo) {
|
|
1226
|
+
(wsUndo as { materializeMount: (m: string) => Promise<void> })
|
|
1227
|
+
.materializeMount('_config')
|
|
1228
|
+
.catch(() => {});
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
console.error(
|
|
1232
|
+
`[host-command] undo-messages agent=${agentName} removed=${n} branch=${branchName}` +
|
|
1233
|
+
` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
return {
|
|
1237
|
+
ok: true,
|
|
1238
|
+
messagesRemoved: n,
|
|
1239
|
+
lastVisible: await this.lastVisiblePreview(agentName),
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
const requested = Math.max(1, Math.min(20, Math.floor(params.turns ?? 1)));
|
|
1244
|
+
let undone = 0;
|
|
1245
|
+
try {
|
|
1246
|
+
for (let i = 0; i < requested; i++) {
|
|
1247
|
+
const r = this.undoLastTurn(agentName);
|
|
1248
|
+
if (!r.undone) break;
|
|
1249
|
+
undone++;
|
|
1250
|
+
}
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
// e.g. "Cannot undo while agent is streaming" — report what happened,
|
|
1253
|
+
// including any turns already undone before the failure.
|
|
1254
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
1255
|
+
if (undone === 0) return { ok: false, error: msg };
|
|
1256
|
+
console.error(`[host-command] undo partially failed after ${undone}/${requested}: ${msg}`);
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
console.error(
|
|
1260
|
+
`[host-command] undo agent=${agentName} requested=${requested} undone=${undone}` +
|
|
1261
|
+
` by=${params.requesterName ?? params.requesterId ?? 'unknown'} (server=${serverId})`,
|
|
1262
|
+
);
|
|
1263
|
+
|
|
1264
|
+
if (undone === 0) {
|
|
1265
|
+
return { ok: true, undone: 0, requested, lastVisible: null };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
return { ok: true, undone, requested, lastVisible: await this.lastVisiblePreview(agentName) };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/**
|
|
1272
|
+
* The last message the agent would see in its context right now, via the
|
|
1273
|
+
* transparent `previewActivation` render (no inference, no Chronicle
|
|
1274
|
+
* writes). Returns null if the preview fails or the context is empty.
|
|
1275
|
+
*/
|
|
1276
|
+
private async lastVisiblePreview(
|
|
1277
|
+
agentName: string,
|
|
1278
|
+
): Promise<{ participant?: string; role?: string; preview?: string } | null> {
|
|
1279
|
+
try {
|
|
1280
|
+
const preview = await this.previewActivation(agentName);
|
|
1281
|
+
const messages = (preview as { messages?: unknown[] }).messages ?? [];
|
|
1282
|
+
const last = messages[messages.length - 1] as
|
|
1283
|
+
| { role?: string; participant?: string; content?: unknown }
|
|
1284
|
+
| undefined;
|
|
1285
|
+
if (!last) return null;
|
|
1286
|
+
const blocks = Array.isArray(last.content) ? last.content : [];
|
|
1287
|
+
const text = blocks
|
|
1288
|
+
.map((b) => (b && typeof b === 'object' && 'text' in b ? String((b as { text: unknown }).text) : ''))
|
|
1289
|
+
.join(' ')
|
|
1290
|
+
.trim();
|
|
1291
|
+
// Surface-injected preambles (<system>…</system>, <backscroll>…
|
|
1292
|
+
// </backscroll>) can dominate the head of a bundled incoming message
|
|
1293
|
+
// and bury the actual conversational line. Strip them for the
|
|
1294
|
+
// preview; fall back to the raw text if nothing else remains.
|
|
1295
|
+
const stripped = text
|
|
1296
|
+
.replace(/<system>[\s\S]*?<\/system>/g, '')
|
|
1297
|
+
.replace(/<backscroll\b[\s\S]*?<\/backscroll>/g, '')
|
|
1298
|
+
.trim();
|
|
1299
|
+
const body = stripped.length > 0 ? stripped : text;
|
|
1300
|
+
return {
|
|
1301
|
+
participant: last.participant,
|
|
1302
|
+
role: last.role,
|
|
1303
|
+
preview: body.length > 400 ? `${body.slice(0, 400)}…` : body,
|
|
1304
|
+
};
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
console.error(
|
|
1307
|
+
'[host-command] post-undo context preview failed:',
|
|
1308
|
+
error instanceof Error ? error.message : error,
|
|
1309
|
+
);
|
|
1310
|
+
return null;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
|
|
843
1314
|
queryInferenceLogs(query?: InferenceLogQuery): InferenceLogQueryResult {
|
|
844
1315
|
const limit = query?.limit ?? 50;
|
|
845
1316
|
const offset = query?.offset ?? 0;
|
|
@@ -1307,6 +1778,7 @@ export class AgentFramework {
|
|
|
1307
1778
|
|
|
1308
1779
|
const agent = new Agent(config, contextManager, this.membrane);
|
|
1309
1780
|
this.agents.set(config.name, agent);
|
|
1781
|
+
this.agentConfigs.set(config.name, config);
|
|
1310
1782
|
|
|
1311
1783
|
// First non-ephemeral agent becomes the primary for message routing
|
|
1312
1784
|
if (!this.primaryAgentName) {
|
|
@@ -1334,6 +1806,9 @@ export class AgentFramework {
|
|
|
1334
1806
|
await this.handleProcessEvent(event);
|
|
1335
1807
|
}
|
|
1336
1808
|
|
|
1809
|
+
// Close idle conversation forks (no-op unless conversations configured)
|
|
1810
|
+
this.sweepExpiredConversations();
|
|
1811
|
+
|
|
1337
1812
|
// Check for inference requests
|
|
1338
1813
|
await this.processInferenceRequests();
|
|
1339
1814
|
|
|
@@ -1392,25 +1867,18 @@ export class AgentFramework {
|
|
|
1392
1867
|
// Compute truncation limit from agent's strategy (maxMessageTokens * 4 chars)
|
|
1393
1868
|
const maxChars = this.getMaxToolResultChars(agent);
|
|
1394
1869
|
|
|
1395
|
-
// Store tool results as a user message (tool_result blocks)
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
return {
|
|
1408
|
-
type: 'tool_result' as const,
|
|
1409
|
-
toolUseId: tc.id,
|
|
1410
|
-
content,
|
|
1411
|
-
isError: tc.result.isError,
|
|
1412
|
-
};
|
|
1413
|
-
});
|
|
1870
|
+
// Store tool results as a user message (tool_result blocks).
|
|
1871
|
+
// Use the history serializer so MCP image blocks become a short
|
|
1872
|
+
// `[image: type, size]` placeholder instead of megabytes of base64
|
|
1873
|
+
// that would corrupt under truncation.
|
|
1874
|
+
const toolResultContent: ContentBlock[] = currentState.toolResults.map(tc => ({
|
|
1875
|
+
type: 'tool_result' as const,
|
|
1876
|
+
toolUseId: tc.id,
|
|
1877
|
+
content: tc.result.isError
|
|
1878
|
+
? tc.result.error ?? 'Unknown error'
|
|
1879
|
+
: toolResultDataToHistoryString(tc.result.data, maxChars),
|
|
1880
|
+
isError: tc.result.isError,
|
|
1881
|
+
}));
|
|
1414
1882
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
1415
1883
|
|
|
1416
1884
|
// Flush any messages that were deferred while waiting for tool results.
|
|
@@ -1482,7 +1950,7 @@ export class AgentFramework {
|
|
|
1482
1950
|
// These events are protocol-level (spec Sections 9 & 14) and always
|
|
1483
1951
|
// represent content intended for the model's context window.
|
|
1484
1952
|
if (event.type === 'mcpl:channel-incoming') {
|
|
1485
|
-
this.handleMcplChannelIncoming(event as unknown as {
|
|
1953
|
+
await this.handleMcplChannelIncoming(event as unknown as {
|
|
1486
1954
|
type: 'mcpl:channel-incoming';
|
|
1487
1955
|
serverId: string;
|
|
1488
1956
|
channelId: string;
|
|
@@ -1578,9 +2046,11 @@ export class AgentFramework {
|
|
|
1578
2046
|
if (!decision.trigger) return;
|
|
1579
2047
|
}
|
|
1580
2048
|
|
|
2049
|
+
// Broadcast requests exclude conversation forks — they are driven by
|
|
2050
|
+
// their own channel's messages, not by framework-wide events.
|
|
1581
2051
|
const targetAgents =
|
|
1582
2052
|
response.requestInference === true
|
|
1583
|
-
? Array.from(this.agents.keys())
|
|
2053
|
+
? Array.from(this.agents.keys()).filter((n) => !this.conversationAgentHomes.has(n))
|
|
1584
2054
|
: response.requestInference;
|
|
1585
2055
|
|
|
1586
2056
|
for (const agentName of targetAgents) {
|
|
@@ -1605,7 +2075,7 @@ export class AgentFramework {
|
|
|
1605
2075
|
* Convert an incoming MCPL channel message to a context message.
|
|
1606
2076
|
* This replaces the old MCPLModule.onProcess() message conversion.
|
|
1607
2077
|
*/
|
|
1608
|
-
private handleMcplChannelIncoming(event: {
|
|
2078
|
+
private async handleMcplChannelIncoming(event: {
|
|
1609
2079
|
type: 'mcpl:channel-incoming';
|
|
1610
2080
|
serverId: string;
|
|
1611
2081
|
channelId: string;
|
|
@@ -1616,7 +2086,7 @@ export class AgentFramework {
|
|
|
1616
2086
|
timestamp: string;
|
|
1617
2087
|
metadata?: Record<string, unknown>;
|
|
1618
2088
|
triggerInference?: boolean;
|
|
1619
|
-
}): void {
|
|
2089
|
+
}): Promise<void> {
|
|
1620
2090
|
const metadata: Record<string, unknown> = {
|
|
1621
2091
|
...event.metadata,
|
|
1622
2092
|
channelId: event.channelId,
|
|
@@ -1627,6 +2097,14 @@ export class AgentFramework {
|
|
|
1627
2097
|
};
|
|
1628
2098
|
if (event.threadId) metadata.threadId = event.threadId;
|
|
1629
2099
|
|
|
2100
|
+
// Per-channel conversation routing: messages go to the channel's fork
|
|
2101
|
+
// agent (spawned from the template on first qualifying message), never
|
|
2102
|
+
// to the primary conversation.
|
|
2103
|
+
if (this.conversationRouter) {
|
|
2104
|
+
await this.routeConversationIncoming(event, metadata);
|
|
2105
|
+
return;
|
|
2106
|
+
}
|
|
2107
|
+
|
|
1630
2108
|
const id = this.addMessage('user', event.content, metadata);
|
|
1631
2109
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
1632
2110
|
|
|
@@ -1642,6 +2120,261 @@ export class AgentFramework {
|
|
|
1642
2120
|
}
|
|
1643
2121
|
}
|
|
1644
2122
|
|
|
2123
|
+
/**
|
|
2124
|
+
* Route an incoming channel message through the ConversationRouter:
|
|
2125
|
+
* deliver to the channel's bound fork agent, spawning it from the template
|
|
2126
|
+
* agent first when the bind policy matches. Unrouted messages are dropped —
|
|
2127
|
+
* the template ("trunk") is a dormant warm checkpoint, not a listener.
|
|
2128
|
+
*/
|
|
2129
|
+
private async routeConversationIncoming(
|
|
2130
|
+
event: {
|
|
2131
|
+
serverId: string;
|
|
2132
|
+
channelId: string;
|
|
2133
|
+
messageId: string;
|
|
2134
|
+
author: { id: string; name: string };
|
|
2135
|
+
content: ContentBlock[];
|
|
2136
|
+
metadata?: Record<string, unknown>;
|
|
2137
|
+
triggerInference?: boolean;
|
|
2138
|
+
},
|
|
2139
|
+
messageMetadata: Record<string, unknown>,
|
|
2140
|
+
): Promise<void> {
|
|
2141
|
+
const router = this.conversationRouter!;
|
|
2142
|
+
const descriptor = this.channelRegistry?.getDescriptor(event.channelId);
|
|
2143
|
+
|
|
2144
|
+
const decision = router.route({
|
|
2145
|
+
channelId: event.channelId,
|
|
2146
|
+
mentioned: event.metadata?.mentioned === true,
|
|
2147
|
+
kind: ConversationRouter.classifyChannel(descriptor, event.metadata),
|
|
2148
|
+
});
|
|
2149
|
+
|
|
2150
|
+
if (decision.kind === 'unbound') {
|
|
2151
|
+
this.emitTrace({
|
|
2152
|
+
type: 'mcpl:conversation-unrouted',
|
|
2153
|
+
channelId: event.channelId,
|
|
2154
|
+
messageId: event.messageId,
|
|
2155
|
+
});
|
|
2156
|
+
return;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
let agent: Agent | undefined;
|
|
2160
|
+
if (decision.kind === 'spawn') {
|
|
2161
|
+
try {
|
|
2162
|
+
agent = await this.createConversationAgent(decision.agentName, event.channelId);
|
|
2163
|
+
router.bind(event.channelId, decision.agentName, decision.generation);
|
|
2164
|
+
this.persistConversationRouterState();
|
|
2165
|
+
this.emitTrace({
|
|
2166
|
+
type: 'mcpl:conversation-spawned',
|
|
2167
|
+
channelId: event.channelId,
|
|
2168
|
+
agentName: decision.agentName,
|
|
2169
|
+
generation: decision.generation,
|
|
2170
|
+
template: router.templateAgent,
|
|
2171
|
+
});
|
|
2172
|
+
} catch (error) {
|
|
2173
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
2174
|
+
console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
|
|
2175
|
+
this.emitTrace({
|
|
2176
|
+
type: 'mcpl:conversation-spawn-failed',
|
|
2177
|
+
channelId: event.channelId,
|
|
2178
|
+
agentName: decision.agentName,
|
|
2179
|
+
error: err.message,
|
|
2180
|
+
});
|
|
2181
|
+
return; // next qualifying message proposes the same spawn again
|
|
2182
|
+
}
|
|
2183
|
+
} else {
|
|
2184
|
+
agent = this.agents.get(decision.agentName);
|
|
2185
|
+
if (!agent) {
|
|
2186
|
+
// Bound agent vanished (e.g. external reset) — unbind so the next
|
|
2187
|
+
// qualifying message respawns a fresh fork.
|
|
2188
|
+
router.unbind(event.channelId);
|
|
2189
|
+
this.emitTrace({
|
|
2190
|
+
type: 'mcpl:conversation-binding-orphaned',
|
|
2191
|
+
channelId: event.channelId,
|
|
2192
|
+
agentName: decision.agentName,
|
|
2193
|
+
});
|
|
2194
|
+
return;
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// Respect an explicit server-config-level veto (shouldTriggerInference)
|
|
2199
|
+
// on top of the router's own trigger policy.
|
|
2200
|
+
const trigger = decision.trigger && event.triggerInference !== false;
|
|
2201
|
+
messageMetadata.triggered = trigger;
|
|
2202
|
+
|
|
2203
|
+
const id = agent.getContextManager().addMessage('user', event.content, messageMetadata);
|
|
2204
|
+
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:channel-incoming' });
|
|
2205
|
+
|
|
2206
|
+
if (trigger) {
|
|
2207
|
+
this.pendingRequests.push({
|
|
2208
|
+
agentName: agent.name,
|
|
2209
|
+
reason: 'mcpl:channel-incoming',
|
|
2210
|
+
source: event.serverId,
|
|
2211
|
+
timestamp: Date.now(),
|
|
2212
|
+
});
|
|
2213
|
+
}
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Spawn a persistent per-channel conversation agent ("fork") from the
|
|
2218
|
+
* template agent: own ContextManager under `conversations/{name}`, seeded
|
|
2219
|
+
* with a copy of the template's compiled context (the SubagentModule
|
|
2220
|
+
* wholesale-copy pattern), registered in the event loop like any agent —
|
|
2221
|
+
* but never primary.
|
|
2222
|
+
*/
|
|
2223
|
+
private async createConversationAgent(name: string, channelId: string): Promise<Agent> {
|
|
2224
|
+
const router = this.conversationRouter!;
|
|
2225
|
+
const templateConfig = this.agentConfigs.get(router.templateAgent);
|
|
2226
|
+
const template = this.agents.get(router.templateAgent);
|
|
2227
|
+
if (!template || !templateConfig) {
|
|
2228
|
+
throw new Error(`conversation template agent "${router.templateAgent}" not found`);
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
const contextManager = await ContextManager.open({
|
|
2232
|
+
store: this.store,
|
|
2233
|
+
namespace: `conversations/${name}`,
|
|
2234
|
+
isolate: true,
|
|
2235
|
+
// Strategy instances are stateful — never share the template's.
|
|
2236
|
+
strategy: router.strategyFactory?.() ?? new PassthroughStrategy(),
|
|
2237
|
+
membrane: this.membrane,
|
|
2238
|
+
debugLogContext: !!process.env.DEBUG_CONTEXT,
|
|
2239
|
+
});
|
|
2240
|
+
|
|
2241
|
+
// Seed with the template's compiled context, renaming the template
|
|
2242
|
+
// participant so the fork reads its inheritance as its own history.
|
|
2243
|
+
// Guard: only seed a genuinely fresh namespace. Generation counters are
|
|
2244
|
+
// persisted precisely so names aren't reused, but if this namespace has
|
|
2245
|
+
// history anyway (counter state lost, crash between spawn and persist),
|
|
2246
|
+
// seeding again would stack another template copy on top of it.
|
|
2247
|
+
const { messages: existing } = await contextManager.compile();
|
|
2248
|
+
if (existing.length === 0) {
|
|
2249
|
+
const { messages: compiled } = await template.getContextManager().compile();
|
|
2250
|
+
for (const msg of compiled) {
|
|
2251
|
+
const participant = msg.participant === template.name ? name : msg.participant;
|
|
2252
|
+
contextManager.addMessage(participant, msg.content);
|
|
2253
|
+
}
|
|
2254
|
+
}
|
|
2255
|
+
|
|
2256
|
+
const config: AgentConfig = { ...templateConfig, name, strategy: undefined };
|
|
2257
|
+
const agent = new Agent(config, contextManager, this.membrane);
|
|
2258
|
+
this.agents.set(name, agent);
|
|
2259
|
+
this.agentConfigs.set(name, config);
|
|
2260
|
+
this.conversationAgentHomes.set(name, channelId);
|
|
2261
|
+
return agent;
|
|
2262
|
+
}
|
|
2263
|
+
|
|
2264
|
+
/** Persist the router's generation counters (see hydration in create()). */
|
|
2265
|
+
private persistConversationRouterState(): void {
|
|
2266
|
+
if (!this.conversationRouter) return;
|
|
2267
|
+
try {
|
|
2268
|
+
this.store.setStateJson(CONVERSATION_ROUTER_STATE_ID, {
|
|
2269
|
+
generations: this.conversationRouter.exportGenerations(),
|
|
2270
|
+
});
|
|
2271
|
+
} catch (error) {
|
|
2272
|
+
console.error('Failed to persist conversation router state:', error);
|
|
2273
|
+
}
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
/**
|
|
2277
|
+
* Remove a closed conversation fork from the framework: its closure turn
|
|
2278
|
+
* has finished, so the home mapping has served its purpose and keeping the
|
|
2279
|
+
* agent registered would just grow every agent scan and broadcast filter
|
|
2280
|
+
* forever. The fork's context stays in Chronicle for investigation.
|
|
2281
|
+
*/
|
|
2282
|
+
private disposeConversationAgent(agentName: string): void {
|
|
2283
|
+
this.closingConversationAgents.delete(agentName);
|
|
2284
|
+
const channelId = this.conversationAgentHomes.get(agentName);
|
|
2285
|
+
this.agents.delete(agentName);
|
|
2286
|
+
this.agentConfigs.delete(agentName);
|
|
2287
|
+
this.conversationAgentHomes.delete(agentName);
|
|
2288
|
+
this.emitTrace({
|
|
2289
|
+
type: 'mcpl:conversation-disposed',
|
|
2290
|
+
agentName,
|
|
2291
|
+
channelId,
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* Scope MCPL context injections for a conversation-bound agent: drop
|
|
2297
|
+
* injections that are another open channel's context (injection namespace
|
|
2298
|
+
* = channelId by adapter convention), keep its own channel and anything
|
|
2299
|
+
* that isn't channel context.
|
|
2300
|
+
*/
|
|
2301
|
+
private scopeInjectionsForAgent(
|
|
2302
|
+
agentName: string,
|
|
2303
|
+
injections: ContextInjection[],
|
|
2304
|
+
): ContextInjection[] {
|
|
2305
|
+
const home = this.conversationAgentHomes.get(agentName);
|
|
2306
|
+
if (!home || !this.channelRegistry || injections.length === 0) {
|
|
2307
|
+
return injections;
|
|
2308
|
+
}
|
|
2309
|
+
const openChannelIds = new Set(
|
|
2310
|
+
this.channelRegistry.getOpenChannels().map((e) => e.descriptor.id),
|
|
2311
|
+
);
|
|
2312
|
+
return injections.filter((inj) => {
|
|
2313
|
+
const ns = inj.namespace;
|
|
2314
|
+
if (!ns || !openChannelIds.has(ns)) return true;
|
|
2315
|
+
return ns === home;
|
|
2316
|
+
});
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2319
|
+
/**
|
|
2320
|
+
* Idle-TTL sweep for conversation bindings (runs at most once per minute
|
|
2321
|
+
* from the event loop). Expired forks get a final system-initiated closure
|
|
2322
|
+
* turn — publish scoping still works because the agent's home channel
|
|
2323
|
+
* mapping is permanent — and the channel unbinds immediately, so the next
|
|
2324
|
+
* qualifying message spawns a fresh fork from the current template.
|
|
2325
|
+
*/
|
|
2326
|
+
private sweepExpiredConversations(): void {
|
|
2327
|
+
if (!this.conversationRouter) return;
|
|
2328
|
+
const now = Date.now();
|
|
2329
|
+
if (now - this.lastConversationSweep < 60_000) return;
|
|
2330
|
+
this.lastConversationSweep = now;
|
|
2331
|
+
|
|
2332
|
+
for (const binding of this.conversationRouter.expired(now)) {
|
|
2333
|
+
this.conversationRouter.unbind(binding.channelId);
|
|
2334
|
+
this.emitTrace({
|
|
2335
|
+
type: 'mcpl:conversation-closed',
|
|
2336
|
+
channelId: binding.channelId,
|
|
2337
|
+
agentName: binding.agentName,
|
|
2338
|
+
reason: 'idle-ttl',
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
const agent = this.agents.get(binding.agentName);
|
|
2342
|
+
if (!agent) {
|
|
2343
|
+
// Agent vanished (external reset) — nothing to close, just make sure
|
|
2344
|
+
// its bookkeeping doesn't linger.
|
|
2345
|
+
this.agentConfigs.delete(binding.agentName);
|
|
2346
|
+
this.conversationAgentHomes.delete(binding.agentName);
|
|
2347
|
+
continue;
|
|
2348
|
+
}
|
|
2349
|
+
agent.getContextManager().addMessage(
|
|
2350
|
+
'user',
|
|
2351
|
+
[{ type: 'text', text: this.conversationRouter.closurePrompt }],
|
|
2352
|
+
{ channelId: binding.channelId, conversationClosure: true },
|
|
2353
|
+
);
|
|
2354
|
+
this.pendingRequests.push({
|
|
2355
|
+
agentName: binding.agentName,
|
|
2356
|
+
reason: 'conversation:closure',
|
|
2357
|
+
source: 'framework',
|
|
2358
|
+
timestamp: now,
|
|
2359
|
+
});
|
|
2360
|
+
// Disposed when the closure stream ends (driveStream finally), with
|
|
2361
|
+
// the reaper below as fallback.
|
|
2362
|
+
this.closingConversationAgents.add(binding.agentName);
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
// Fallback reaper: a closing fork whose closure inference never ran
|
|
2366
|
+
// (request dropped as stale, inference policy veto) would otherwise stay
|
|
2367
|
+
// registered forever. No active stream + no pending request = done.
|
|
2368
|
+
for (const agentName of [...this.closingConversationAgents]) {
|
|
2369
|
+
if (
|
|
2370
|
+
!this.activeStreams.has(agentName) &&
|
|
2371
|
+
!this.pendingRequests.some((r) => r.agentName === agentName)
|
|
2372
|
+
) {
|
|
2373
|
+
this.disposeConversationAgent(agentName);
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
|
|
1645
2378
|
/**
|
|
1646
2379
|
* Convert an MCPL push event to a context message.
|
|
1647
2380
|
*/
|
|
@@ -1658,7 +2391,9 @@ export class AgentFramework {
|
|
|
1658
2391
|
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
|
|
1659
2392
|
|
|
1660
2393
|
if (event.triggerInference) {
|
|
1661
|
-
|
|
2394
|
+
// Default broadcast excludes conversation forks (channel-driven).
|
|
2395
|
+
const targetAgents = event.targetAgents
|
|
2396
|
+
?? [...this.agents.keys()].filter((n) => !this.conversationAgentHomes.has(n));
|
|
1662
2397
|
for (const agentName of targetAgents) {
|
|
1663
2398
|
this.pendingRequests.push({
|
|
1664
2399
|
agentName,
|
|
@@ -1753,6 +2488,11 @@ export class AgentFramework {
|
|
|
1753
2488
|
let injections: ContextInjection[] | undefined;
|
|
1754
2489
|
|
|
1755
2490
|
// Module gatherContext (fail-open, 5s timeout per module)
|
|
2491
|
+
// NOTE: module injections are NOT channel-scoped — only the MCPL hook
|
|
2492
|
+
// injections below pass through scopeInjectionsForAgent (channel
|
|
2493
|
+
// context arrives via beforeInference hooks by adapter convention). A
|
|
2494
|
+
// module that starts emitting per-channel context must scope it per
|
|
2495
|
+
// agent itself, or conversation forks will see cross-channel content.
|
|
1756
2496
|
try {
|
|
1757
2497
|
const moduleInjections = await this.moduleRegistry.gatherContext(agent.name);
|
|
1758
2498
|
if (moduleInjections.length > 0) {
|
|
@@ -1766,7 +2506,10 @@ export class AgentFramework {
|
|
|
1766
2506
|
if (this.hookOrchestrator) {
|
|
1767
2507
|
try {
|
|
1768
2508
|
const hookParams = this.buildBeforeInferenceParams(agent, trigger);
|
|
1769
|
-
const hookInjections =
|
|
2509
|
+
const hookInjections = this.scopeInjectionsForAgent(
|
|
2510
|
+
agent.name,
|
|
2511
|
+
await this.hookOrchestrator.beforeInference(hookParams),
|
|
2512
|
+
);
|
|
1770
2513
|
if (hookInjections.length > 0) {
|
|
1771
2514
|
injections = injections ? [...injections, ...hookInjections] : hookInjections;
|
|
1772
2515
|
}
|
|
@@ -1819,6 +2562,13 @@ export class AgentFramework {
|
|
|
1819
2562
|
const myStreamId = agent.streamId;
|
|
1820
2563
|
let hadToolCalls = false;
|
|
1821
2564
|
|
|
2565
|
+
// Typing indicator: show "<agent> is typing…" in the channel she's
|
|
2566
|
+
// responding to, for the whole duration of this turn. Started here (paired
|
|
2567
|
+
// with the finally below, so it can never leak) and refreshed on a 7s
|
|
2568
|
+
// interval by the ChannelRegistry until stopped on any exit path.
|
|
2569
|
+
const typingChannel = this.channelRegistry?.getDefaultPublishChannel();
|
|
2570
|
+
if (typingChannel) this.channelRegistry!.startTyping(typingChannel);
|
|
2571
|
+
|
|
1822
2572
|
try {
|
|
1823
2573
|
for await (const event of stream) {
|
|
1824
2574
|
switch (event.type) {
|
|
@@ -1852,13 +2602,27 @@ export class AgentFramework {
|
|
|
1852
2602
|
calls: event.calls.map((c) => ({ id: c.id, name: c.name, input: c.input })),
|
|
1853
2603
|
});
|
|
1854
2604
|
|
|
1855
|
-
// Build assistant content blocks
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2605
|
+
// Build assistant content blocks for this round. Prefer the
|
|
2606
|
+
// membrane's verbatim round content (membrane ≥0.5.64): it
|
|
2607
|
+
// preserves native thinking / redacted_thinking blocks with
|
|
2608
|
+
// their signatures IN ORDER — the API requires signed thinking
|
|
2609
|
+
// to precede its tool_use in the same assistant turn, so
|
|
2610
|
+
// rebuilding from preamble + calls would break thinking+tools.
|
|
2611
|
+
const roundContent = (event.context as { roundContent?: ContentBlock[] }).roundContent;
|
|
2612
|
+
let assistantBlocks: ContentBlock[];
|
|
2613
|
+
if (roundContent && roundContent.length > 0) {
|
|
2614
|
+
assistantBlocks = roundContent.filter(
|
|
2615
|
+
(b) => b.type !== 'tool_result'
|
|
2616
|
+
);
|
|
2617
|
+
} else {
|
|
2618
|
+
// Fallback (older membrane / XML tool mode): preamble + calls
|
|
2619
|
+
assistantBlocks = [];
|
|
2620
|
+
if (event.context.preamble) {
|
|
2621
|
+
assistantBlocks.push({ type: 'text', text: event.context.preamble });
|
|
2622
|
+
}
|
|
2623
|
+
for (const c of event.calls) {
|
|
2624
|
+
assistantBlocks.push({ type: 'tool_use', id: c.id, name: c.name, input: c.input as Record<string, unknown> });
|
|
2625
|
+
}
|
|
1862
2626
|
}
|
|
1863
2627
|
this.pendingAssistantBlocks.set(agent.name, assistantBlocks);
|
|
1864
2628
|
|
|
@@ -1909,7 +2673,9 @@ export class AgentFramework {
|
|
|
1909
2673
|
const toolResultContent: ContentBlock[] = readyState.toolResults.map(tc => ({
|
|
1910
2674
|
type: 'tool_result' as const,
|
|
1911
2675
|
toolUseId: tc.id,
|
|
1912
|
-
content: tc.result.isError
|
|
2676
|
+
content: tc.result.isError
|
|
2677
|
+
? (tc.result.error ?? 'Unknown error')
|
|
2678
|
+
: toolResultDataToHistoryString(tc.result.data),
|
|
1913
2679
|
isError: tc.result.isError,
|
|
1914
2680
|
}));
|
|
1915
2681
|
agent.getContextManager().addMessage('user', toolResultContent);
|
|
@@ -1917,18 +2683,20 @@ export class AgentFramework {
|
|
|
1917
2683
|
}
|
|
1918
2684
|
|
|
1919
2685
|
// Add assistant response to context.
|
|
1920
|
-
// If we had tool calls,
|
|
1921
|
-
//
|
|
1922
|
-
//
|
|
1923
|
-
//
|
|
1924
|
-
|
|
1925
|
-
|
|
2686
|
+
// If we had tool calls, each round's blocks (thinking + text +
|
|
2687
|
+
// tool_use) were already stored as pendingAssistantBlocks and
|
|
2688
|
+
// flushed when tool results arrived. Only store TRAILING content
|
|
2689
|
+
// — blocks after the last tool block. A type-based filter would
|
|
2690
|
+
// double-store earlier rounds' text/thinking AND detach signed
|
|
2691
|
+
// thinking blocks from their tool_use turn (the API requires
|
|
2692
|
+
// thinking to precede tool_use in the same assistant turn).
|
|
2693
|
+
const lastToolIdx = response.content.reduce(
|
|
2694
|
+
(last: number, b: ContentBlock, i: number) =>
|
|
2695
|
+
b.type === 'tool_use' || b.type === 'tool_result' ? i : last,
|
|
2696
|
+
-1
|
|
1926
2697
|
);
|
|
1927
|
-
if (
|
|
1928
|
-
|
|
1929
|
-
const trailingContent = response.content.filter(
|
|
1930
|
-
(block: ContentBlock) => block.type !== 'tool_use' && block.type !== 'tool_result'
|
|
1931
|
-
);
|
|
2698
|
+
if (lastToolIdx >= 0) {
|
|
2699
|
+
const trailingContent = response.content.slice(lastToolIdx + 1);
|
|
1932
2700
|
if (trailingContent.length > 0) {
|
|
1933
2701
|
agent.addAssistantResponse(trailingContent);
|
|
1934
2702
|
}
|
|
@@ -2034,6 +2802,24 @@ export class AgentFramework {
|
|
|
2034
2802
|
stopReason: response.stopReason,
|
|
2035
2803
|
});
|
|
2036
2804
|
|
|
2805
|
+
// Surface refusals instead of going silently mute: stderr line
|
|
2806
|
+
// (headless inference failures are otherwise under-logged) + an
|
|
2807
|
+
// emoji reaction on the triggering Discord message, keyed by the
|
|
2808
|
+
// refusal category from stop_details.
|
|
2809
|
+
if (response.stopReason === 'refusal') {
|
|
2810
|
+
// stop_details lives on the raw PROVIDER response
|
|
2811
|
+
// (response.raw is RawAccess = { request, response, headers }).
|
|
2812
|
+
const stopDetails = (response.raw?.response as {
|
|
2813
|
+
stop_details?: { category?: string; explanation?: string };
|
|
2814
|
+
} | undefined)?.stop_details;
|
|
2815
|
+
const category = stopDetails?.category ?? 'unknown';
|
|
2816
|
+
console.error(
|
|
2817
|
+
`[inference-refusal] agent=${agent.name} category=${category}` +
|
|
2818
|
+
(stopDetails?.explanation ? ` explanation=${stopDetails.explanation}` : ''),
|
|
2819
|
+
);
|
|
2820
|
+
void this.reactToRefusal(agent.name, category);
|
|
2821
|
+
}
|
|
2822
|
+
|
|
2037
2823
|
// Dispatch speech (and thoughts if any)
|
|
2038
2824
|
if (speechContent.length > 0 || thoughts.length > 0) {
|
|
2039
2825
|
const speechContext: SpeechContext = {
|
|
@@ -2052,6 +2838,81 @@ export class AgentFramework {
|
|
|
2052
2838
|
);
|
|
2053
2839
|
}
|
|
2054
2840
|
|
|
2841
|
+
// Host-owned output routing (see LOCUS-ROUTING-DESIGN). On a
|
|
2842
|
+
// text-only turn (no tool calls => speechContent populated),
|
|
2843
|
+
// publish the agent's speech to the conversational locus — the
|
|
2844
|
+
// most recent incoming channel, tracked cross-surface in the
|
|
2845
|
+
// ChannelRegistry. This replaces discord-mcpl's per-surface sticky
|
|
2846
|
+
// auto-post. Tool-call turns produce `thoughts`, not `speech`, so
|
|
2847
|
+
// they are never routed here — which is precisely how the `think`
|
|
2848
|
+
// tool (and any explicit send tool) yields a silent turn.
|
|
2849
|
+
if (speechContent.length > 0 && this.channelRegistry) {
|
|
2850
|
+
const speechText = speechContent
|
|
2851
|
+
.map((b) => (b as ContentBlock & { type: 'text' }).text)
|
|
2852
|
+
.join('\n')
|
|
2853
|
+
.trim();
|
|
2854
|
+
if (speechText) {
|
|
2855
|
+
try {
|
|
2856
|
+
await this.channelRegistry.routeSpeech(agent.name, speechText);
|
|
2857
|
+
} catch (err) {
|
|
2858
|
+
console.error('speech routing failed:', err);
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
} else if (this.channelRegistry && hadToolCalls && allText.length > 0) {
|
|
2862
|
+
// Tool-call turn that also produced prose. Per design, route the
|
|
2863
|
+
// prose to the locus as a reply UNLESS the turn used an explicit
|
|
2864
|
+
// send/publish tool (channel_publish, *--send_message,
|
|
2865
|
+
// *--reply_message, *--send_dm) — already delivered, so routing
|
|
2866
|
+
// again would double-post. `think` and other non-sending tools
|
|
2867
|
+
// (shell, workspace, etc.) still let the agent speak in the same
|
|
2868
|
+
// turn. The global speech/thoughts split is left untouched
|
|
2869
|
+
// (module/TUI rendering unaffected) — this only governs what
|
|
2870
|
+
// reaches the channel.
|
|
2871
|
+
// Tools whose presence suppresses the trailing prose, for two
|
|
2872
|
+
// distinct reasons:
|
|
2873
|
+
// - `skip_reply` — the agent EXPLICITLY chose not to reply this
|
|
2874
|
+
// turn (the deliberate "stay silent" signal).
|
|
2875
|
+
// - explicit delivery tools (channel_publish / *send_message /
|
|
2876
|
+
// *reply_message / *send_dm) — already sent the message, so
|
|
2877
|
+
// routing it again would double-post.
|
|
2878
|
+
// `think` is deliberately NOT here: it is silent *reasoning*, but
|
|
2879
|
+
// prose written after a think is the agent's actual reply and must
|
|
2880
|
+
// be delivered. (A think-only turn has no trailing prose and stays
|
|
2881
|
+
// silent via the `!t` check below.)
|
|
2882
|
+
const SILENCING = new Set([
|
|
2883
|
+
'skip_reply', 'channel_publish', 'send_message', 'reply_message', 'send_dm',
|
|
2884
|
+
]);
|
|
2885
|
+
const bare = (n: string) => (n.includes('--') ? n.split('--').pop()! : n);
|
|
2886
|
+
const toolNames = response.content
|
|
2887
|
+
.filter((b) => b.type === 'tool_use')
|
|
2888
|
+
.map((b) => (b as unknown as { name?: string }).name)
|
|
2889
|
+
.filter((n): n is string => typeof n === 'string');
|
|
2890
|
+
const silenced = toolNames.some((n) => SILENCING.has(bare(n)));
|
|
2891
|
+
const t = allText
|
|
2892
|
+
.map((b) => (b as ContentBlock & { type: 'text' }).text)
|
|
2893
|
+
.join('\n')
|
|
2894
|
+
.trim();
|
|
2895
|
+
if (silenced || !t) {
|
|
2896
|
+
console.error(
|
|
2897
|
+
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ') || 'none'}] -> NOT routed ` +
|
|
2898
|
+
`(${silenced ? 'silencing tool / explicit send' : 'no prose'})`,
|
|
2899
|
+
);
|
|
2900
|
+
} else {
|
|
2901
|
+
console.error(
|
|
2902
|
+
`[routing] ${agent.name}: tool-call turn [${toolNames.join(', ')}] -> routing trailing prose (${t.length} chars) as reply`,
|
|
2903
|
+
);
|
|
2904
|
+
try {
|
|
2905
|
+
await this.channelRegistry.routeSpeech(agent.name, t);
|
|
2906
|
+
} catch (err) {
|
|
2907
|
+
console.error('speech routing failed:', err);
|
|
2908
|
+
}
|
|
2909
|
+
}
|
|
2910
|
+
}
|
|
2911
|
+
// NOTE: agent.reset() + onInferenceEnded() already ran above, BEFORE
|
|
2912
|
+
// dispatchSpeech (main's zombie-subagent fix, PR #32 — sync listeners
|
|
2913
|
+
// must see status === 'idle'). Locus routing is speech dispatch and
|
|
2914
|
+
// doesn't depend on the status field, so it correctly runs after.
|
|
2915
|
+
|
|
2055
2916
|
break;
|
|
2056
2917
|
}
|
|
2057
2918
|
|
|
@@ -2147,9 +3008,18 @@ export class AgentFramework {
|
|
|
2147
3008
|
agent.reset();
|
|
2148
3009
|
this.eventGate?.onInferenceEnded(agent.name);
|
|
2149
3010
|
} finally {
|
|
3011
|
+
// Stop the typing indicator on every exit path (complete, error,
|
|
3012
|
+
// exhausted, abort) so it never sticks after the turn ends.
|
|
3013
|
+
this.channelRegistry?.stopTyping();
|
|
2150
3014
|
this.activeStreams.delete(agent.name);
|
|
2151
3015
|
this.pendingAssistantBlocks.delete(agent.name);
|
|
2152
3016
|
|
|
3017
|
+
// A conversation fork whose TTL closure turn just finished is done for
|
|
3018
|
+
// good — dispose it so the agent map doesn't grow monotonically.
|
|
3019
|
+
if (this.closingConversationAgents.has(agent.name)) {
|
|
3020
|
+
this.disposeConversationAgent(agent.name);
|
|
3021
|
+
}
|
|
3022
|
+
|
|
2153
3023
|
// Flush any deferred messages (e.g. if stream failed while tools were pending)
|
|
2154
3024
|
if (this.deferredMessages.length > 0 && this.pendingAssistantBlocks.size === 0) {
|
|
2155
3025
|
const deferred = this.deferredMessages.splice(0);
|
|
@@ -2216,17 +3086,57 @@ export class AgentFramework {
|
|
|
2216
3086
|
}
|
|
2217
3087
|
|
|
2218
3088
|
private toMembraneToolResult(callId: string, afResult: ToolResult, maxChars?: number): MembraneToolResult {
|
|
2219
|
-
let content: string;
|
|
2220
3089
|
if (afResult.isError) {
|
|
2221
|
-
content
|
|
2222
|
-
}
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
3090
|
+
return { toolUseId: callId, content: afResult.error ?? 'Unknown error', isError: true };
|
|
3091
|
+
}
|
|
3092
|
+
// MCPL tool results arrive as `data: McpToolResultContent[]` — preserve image
|
|
3093
|
+
// blocks natively rather than JSON-stringifying them away. Anything else
|
|
3094
|
+
// (objects, scalars) falls through to JSON. The error path was handled
|
|
3095
|
+
// above, so isError is always false on these return paths.
|
|
3096
|
+
const blocks = this.tryNativeToolResultContent(afResult.data, maxChars);
|
|
3097
|
+
if (blocks) {
|
|
3098
|
+
return { toolUseId: callId, content: blocks, isError: false };
|
|
3099
|
+
}
|
|
3100
|
+
let content = JSON.stringify(afResult.data);
|
|
3101
|
+
if (maxChars && content.length > maxChars) {
|
|
3102
|
+
content = safeSlice(content, 0, maxChars)
|
|
3103
|
+
+ '\n\n[truncated — original was ' + content.length + ' chars]';
|
|
3104
|
+
}
|
|
3105
|
+
return { toolUseId: callId, content, isError: false };
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
/**
|
|
3109
|
+
* If `data` is an MCP tool-result content array carrying at least one image,
|
|
3110
|
+
* convert to Membrane's native ToolResultContentBlock[]. Returns null when
|
|
3111
|
+
* the array is text-only (let JSON path handle it; saves a code path).
|
|
3112
|
+
* `maxChars`, when provided, caps each accompanying text block so an image
|
|
3113
|
+
* inlined alongside an enormous text payload can't blow the context.
|
|
3114
|
+
*/
|
|
3115
|
+
private tryNativeToolResultContent(data: unknown, maxChars?: number): ToolResultContentBlock[] | null {
|
|
3116
|
+
if (!Array.isArray(data)) return null;
|
|
3117
|
+
let hasImage = false;
|
|
3118
|
+
const blocks: ToolResultContentBlock[] = [];
|
|
3119
|
+
for (const raw of data) {
|
|
3120
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
3121
|
+
const b = raw as { type?: unknown; text?: unknown; data?: unknown; mimeType?: unknown };
|
|
3122
|
+
if (b.type === 'text' && typeof b.text === 'string') {
|
|
3123
|
+
let text = b.text;
|
|
3124
|
+
if (maxChars && text.length > maxChars) {
|
|
3125
|
+
text = safeSlice(text, 0, maxChars)
|
|
3126
|
+
+ '\n\n[truncated — original was ' + text.length + ' chars]';
|
|
3127
|
+
}
|
|
3128
|
+
blocks.push({ type: 'text', text });
|
|
3129
|
+
} else if (b.type === 'image' && typeof b.data === 'string' && typeof b.mimeType === 'string') {
|
|
3130
|
+
hasImage = true;
|
|
3131
|
+
blocks.push({
|
|
3132
|
+
type: 'image',
|
|
3133
|
+
source: { type: 'base64', data: b.data, mediaType: b.mimeType },
|
|
3134
|
+
});
|
|
3135
|
+
} else {
|
|
3136
|
+
return null; // unknown shape — bail to JSON path
|
|
2227
3137
|
}
|
|
2228
3138
|
}
|
|
2229
|
-
return
|
|
3139
|
+
return hasImage ? blocks : null;
|
|
2230
3140
|
}
|
|
2231
3141
|
|
|
2232
3142
|
private getMaxToolResultChars(agent: Agent): number | undefined {
|
|
@@ -2320,12 +3230,27 @@ export class AgentFramework {
|
|
|
2320
3230
|
return;
|
|
2321
3231
|
}
|
|
2322
3232
|
|
|
2323
|
-
// Route
|
|
2324
|
-
if (enrichedCall.name === '
|
|
3233
|
+
// Route gate_status tool
|
|
3234
|
+
if (enrichedCall.name === 'gate_status' && this.eventGate) {
|
|
2325
3235
|
this.dispatchGateToolCall(agentName, enrichedCall);
|
|
2326
3236
|
return;
|
|
2327
3237
|
}
|
|
2328
3238
|
|
|
3239
|
+
// Route sleep / wake tools
|
|
3240
|
+
if ((enrichedCall.name === 'sleep' || enrichedCall.name === 'wake') && this.eventGate) {
|
|
3241
|
+
this.dispatchSleepToolCall(agentName, enrichedCall);
|
|
3242
|
+
return;
|
|
3243
|
+
}
|
|
3244
|
+
|
|
3245
|
+
// Route synthesized 'think' (private reasoning) and 'skip_reply' (deliberate
|
|
3246
|
+
// stay-silent) tools — handled by the channel registry like the other
|
|
3247
|
+
// synthesized channel tools, but they aren't `channel_`-prefixed so they
|
|
3248
|
+
// need an explicit route here.
|
|
3249
|
+
if ((enrichedCall.name === 'think' || enrichedCall.name === 'skip_reply') && this.channelRegistry) {
|
|
3250
|
+
this.dispatchChannelToolCall(agentName, enrichedCall);
|
|
3251
|
+
return;
|
|
3252
|
+
}
|
|
3253
|
+
|
|
2329
3254
|
const sepIndex = enrichedCall.name.indexOf('--');
|
|
2330
3255
|
const moduleName = sepIndex >= 0 ? enrichedCall.name.substring(0, sepIndex) : 'unknown';
|
|
2331
3256
|
const toolName = sepIndex >= 0 ? call.name.substring(sepIndex + 2) : call.name;
|
|
@@ -2479,6 +3404,27 @@ export class AgentFramework {
|
|
|
2479
3404
|
}
|
|
2480
3405
|
|
|
2481
3406
|
private emitTrace(event: { type: TraceEvent['type']; [key: string]: unknown }): void {
|
|
3407
|
+
// Centralized inference-health observability. Every terminal failure path
|
|
3408
|
+
// funnels through an `inference:exhausted` trace and every successful model
|
|
3409
|
+
// response through `inference:completed`, so intercepting here is the one
|
|
3410
|
+
// place that reliably sees all outcomes regardless of which code path
|
|
3411
|
+
// produced them. In headless/daemon mode no trace client is attached, so
|
|
3412
|
+
// without this the only durable record of a failed inference is a field in
|
|
3413
|
+
// llm-calls.jsonl — invisible to operator, agent, and monitoring.
|
|
3414
|
+
if (event.type === 'inference:exhausted') {
|
|
3415
|
+
this.noteInferenceExhausted(
|
|
3416
|
+
(event.agentName as string) ?? 'unknown',
|
|
3417
|
+
(event.error as string) ?? 'unknown error',
|
|
3418
|
+
);
|
|
3419
|
+
} else if (event.type === 'inference:completed') {
|
|
3420
|
+
// A successful response — even mid-turn between tool calls — proves the
|
|
3421
|
+
// agent isn't hard-down; clear its consecutive-failure streak.
|
|
3422
|
+
const name = event.agentName as string | undefined;
|
|
3423
|
+
if (name && this.consecutiveInferenceFailures.get(name)) {
|
|
3424
|
+
this.consecutiveInferenceFailures.set(name, 0);
|
|
3425
|
+
}
|
|
3426
|
+
}
|
|
3427
|
+
|
|
2482
3428
|
const traceEvent = {
|
|
2483
3429
|
...event,
|
|
2484
3430
|
timestamp: Date.now(),
|
|
@@ -2493,6 +3439,59 @@ export class AgentFramework {
|
|
|
2493
3439
|
}
|
|
2494
3440
|
}
|
|
2495
3441
|
|
|
3442
|
+
/**
|
|
3443
|
+
* Handle a fully-exhausted inference (the agent could not produce a response
|
|
3444
|
+
* this turn, after retries). Severity here is high — the agent can't think
|
|
3445
|
+
* at all — yet historically the only durable record was a buried field in
|
|
3446
|
+
* llm-calls.jsonl. This surfaces it three ways:
|
|
3447
|
+
*
|
|
3448
|
+
* 1. stderr — always, with the underlying API reason. The place an
|
|
3449
|
+
* operator greps; previously empty in headless mode.
|
|
3450
|
+
* 2. chronicle marker — a `[inference-failed]` message so the agent itself
|
|
3451
|
+
* learns its turn failed and why (otherwise it's an experiential blank,
|
|
3452
|
+
* indistinguishable from "not addressed"). addMessage does NOT request
|
|
3453
|
+
* inference, so this never causes a retry/wake loop.
|
|
3454
|
+
* 3. escalation — after N consecutive failures the agent is hard-down;
|
|
3455
|
+
* log that loudly (a repeated identical failure is the textbook signal).
|
|
3456
|
+
*/
|
|
3457
|
+
private noteInferenceExhausted(agentName: string, reason: string): void {
|
|
3458
|
+
const streak = (this.consecutiveInferenceFailures.get(agentName) ?? 0) + 1;
|
|
3459
|
+
this.consecutiveInferenceFailures.set(agentName, streak);
|
|
3460
|
+
|
|
3461
|
+
// (1) Durable stderr line — works in headless/daemon mode with no client.
|
|
3462
|
+
console.error(`[inference-failed] agent=${agentName} consecutive=${streak}: ${reason}`);
|
|
3463
|
+
|
|
3464
|
+
// (2) Agent-facing chronicle marker (no inference triggered → no loop).
|
|
3465
|
+
const agent = this.agents.get(agentName);
|
|
3466
|
+
if (agent) {
|
|
3467
|
+
try {
|
|
3468
|
+
agent.getContextManager().addMessage(
|
|
3469
|
+
'user',
|
|
3470
|
+
[{
|
|
3471
|
+
type: 'text',
|
|
3472
|
+
text:
|
|
3473
|
+
`[inference-failed] Your previous turn did not complete: the model ` +
|
|
3474
|
+
`call failed and produced no response, so nothing was sent. Reason: ` +
|
|
3475
|
+
`${reason}. If this recurs with the same cause, change approach ` +
|
|
3476
|
+
`rather than retrying identically (e.g. drop an oversized attachment ` +
|
|
3477
|
+
`or an unsupported setting).`,
|
|
3478
|
+
}],
|
|
3479
|
+
{ system: true, kind: 'inference-failed', reason, consecutive: streak },
|
|
3480
|
+
);
|
|
3481
|
+
} catch (err) {
|
|
3482
|
+
console.error(`[inference-failed] could not record chronicle marker for ${agentName}:`, err);
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
|
|
3486
|
+
// (3) Hard-down escalation on repeated identical failure.
|
|
3487
|
+
if (streak >= this.inferenceFailureEscalationThreshold) {
|
|
3488
|
+
console.error(
|
|
3489
|
+
`[inference-hard-down] agent=${agentName} has FAILED ${streak} consecutive ` +
|
|
3490
|
+
`inferences — it cannot complete a turn. Last reason: ${reason}`,
|
|
3491
|
+
);
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
|
|
2496
3495
|
// ==========================================================================
|
|
2497
3496
|
// MCPL subsystem wiring
|
|
2498
3497
|
// ==========================================================================
|
|
@@ -2563,6 +3562,26 @@ export class AgentFramework {
|
|
|
2563
3562
|
}
|
|
2564
3563
|
},
|
|
2565
3564
|
shouldTriggerInference: triggerFilter,
|
|
3565
|
+
// A text-only turn whose speech couldn't be delivered must not vanish
|
|
3566
|
+
// silently: record a `[discord-send-failed]` marker in chronicle so the
|
|
3567
|
+
// agent sees, on her next turn, that her reply never reached the human.
|
|
3568
|
+
// addMessage() alone does not request inference, so this never wakes
|
|
3569
|
+
// her (matching the `discord-send-failed-skip` gate intent: context
|
|
3570
|
+
// yes, wake no).
|
|
3571
|
+
onRouteFailure: ({ channelId, reason, textLen }) => {
|
|
3572
|
+
try {
|
|
3573
|
+
this.addMessage(
|
|
3574
|
+
'user',
|
|
3575
|
+
[{
|
|
3576
|
+
type: 'text',
|
|
3577
|
+
text: `[discord-send-failed] Your previous reply (${textLen} chars) could not be delivered to ${channelId ?? 'the channel'} (${reason}). It was saved to your archive but the human did not receive it.`,
|
|
3578
|
+
}],
|
|
3579
|
+
{ system: true, kind: 'discord-send-failed', channelId: channelId ?? '', reason },
|
|
3580
|
+
);
|
|
3581
|
+
} catch (err) {
|
|
3582
|
+
console.error('onRouteFailure: failed to record send-failure marker:', err);
|
|
3583
|
+
}
|
|
3584
|
+
},
|
|
2566
3585
|
},
|
|
2567
3586
|
);
|
|
2568
3587
|
|
|
@@ -2716,6 +3735,21 @@ export class AgentFramework {
|
|
|
2716
3735
|
this.channelRegistry?.handleIncoming(connection.id, params, responder as never);
|
|
2717
3736
|
});
|
|
2718
3737
|
|
|
3738
|
+
// Handle host-level admin commands from a surface (e.g. Discord /undo)
|
|
3739
|
+
connection.on('host-command', async (
|
|
3740
|
+
params: HostCommandParams,
|
|
3741
|
+
responder?: { respond: (result: unknown) => void; respondError: (code: number, message: string) => void },
|
|
3742
|
+
) => {
|
|
3743
|
+
if (!responder) return;
|
|
3744
|
+
try {
|
|
3745
|
+
const result = await this.handleHostCommand(connection.id, params ?? {});
|
|
3746
|
+
responder.respond(result);
|
|
3747
|
+
} catch (error) {
|
|
3748
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
3749
|
+
responder.respondError(-32603, err.message);
|
|
3750
|
+
}
|
|
3751
|
+
});
|
|
3752
|
+
|
|
2719
3753
|
// Handle dynamic tool list changes (notifications/tools/list_changed)
|
|
2720
3754
|
connection.on('tools-list-changed', () => {
|
|
2721
3755
|
this.handleToolsListChanged(connection.id);
|
|
@@ -2887,11 +3921,22 @@ export class AgentFramework {
|
|
|
2887
3921
|
}
|
|
2888
3922
|
}
|
|
2889
3923
|
|
|
2890
|
-
// Convert MCP tool result to framework ToolResult
|
|
3924
|
+
// Convert MCP tool result to framework ToolResult.
|
|
3925
|
+
// When the result contains non-text blocks (e.g. images from an MCP
|
|
3926
|
+
// tool like zulip-mcp's fetch_attachment), pass the full content array
|
|
3927
|
+
// through so toMembraneToolResult can preserve image blocks natively.
|
|
3928
|
+
// Text-only results still collapse to a joined string for backward
|
|
3929
|
+
// compatibility with callers that expect data to be string-ish.
|
|
2891
3930
|
const textContent = result.content
|
|
2892
3931
|
?.filter((c) => c.type === 'text' && c.text)
|
|
2893
3932
|
.map((c) => c.text!)
|
|
2894
3933
|
.join('\n');
|
|
3934
|
+
const hasNonText = result.content?.some((c) => c.type !== 'text');
|
|
3935
|
+
const data = result.isError
|
|
3936
|
+
? undefined
|
|
3937
|
+
: hasNonText
|
|
3938
|
+
? result.content
|
|
3939
|
+
: (textContent || undefined);
|
|
2895
3940
|
|
|
2896
3941
|
this.pushEvent({
|
|
2897
3942
|
type: 'tool-result',
|
|
@@ -2900,7 +3945,7 @@ export class AgentFramework {
|
|
|
2900
3945
|
moduleName: `mcpl:${serverId}`,
|
|
2901
3946
|
result: {
|
|
2902
3947
|
success: !result.isError,
|
|
2903
|
-
data
|
|
3948
|
+
data,
|
|
2904
3949
|
error: result.isError ? (textContent || 'Tool call failed') : undefined,
|
|
2905
3950
|
isError: result.isError ?? false,
|
|
2906
3951
|
},
|
|
@@ -2927,6 +3972,43 @@ export class AgentFramework {
|
|
|
2927
3972
|
* Dispatch a synthesized channel tool call.
|
|
2928
3973
|
*/
|
|
2929
3974
|
private dispatchChannelToolCall(agentName: string, call: ToolCall): void {
|
|
3975
|
+
// Conversation forks act only on their home channel. channel_publish and
|
|
3976
|
+
// channel_close default a missing channelId to home and reject foreign
|
|
3977
|
+
// ones; channel_open is rejected outright — opening channels mutates
|
|
3978
|
+
// framework-global state (the open-channel set every agent's injections
|
|
3979
|
+
// are scoped against), which is not a fork's call to make.
|
|
3980
|
+
const home = this.conversationAgentHomes.get(agentName);
|
|
3981
|
+
if (home) {
|
|
3982
|
+
const reject = (error: string): void => {
|
|
3983
|
+
this.emitTrace({
|
|
3984
|
+
type: 'tool:failed', module: 'channels', tool: call.name, callId: call.id,
|
|
3985
|
+
error: `conversation agent ${agentName}: ${error}`,
|
|
3986
|
+
});
|
|
3987
|
+
this.pushEvent({
|
|
3988
|
+
type: 'tool-result',
|
|
3989
|
+
callId: call.id,
|
|
3990
|
+
agentName,
|
|
3991
|
+
moduleName: 'channels',
|
|
3992
|
+
result: { success: false, error, isError: true },
|
|
3993
|
+
});
|
|
3994
|
+
};
|
|
3995
|
+
|
|
3996
|
+
if (call.name === 'channel_open') {
|
|
3997
|
+
reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
if (call.name === 'channel_publish' || call.name === 'channel_close') {
|
|
4001
|
+
const input = (call.input ?? {}) as { channelId?: string };
|
|
4002
|
+
if (!input.channelId) {
|
|
4003
|
+
call = { ...call, input: { ...input, channelId: home } };
|
|
4004
|
+
} else if (input.channelId !== home) {
|
|
4005
|
+
const verb = call.name === 'channel_publish' ? 'publishing to' : 'closing';
|
|
4006
|
+
reject(`This conversation is bound to channel ${home}; ${verb} ${input.channelId} is not allowed.`);
|
|
4007
|
+
return;
|
|
4008
|
+
}
|
|
4009
|
+
}
|
|
4010
|
+
}
|
|
4011
|
+
|
|
2930
4012
|
this.emitTrace({ type: 'tool:started', module: 'channels', tool: call.name, callId: call.id, input: call.input });
|
|
2931
4013
|
const startTime = Date.now();
|
|
2932
4014
|
|
|
@@ -2955,6 +4037,53 @@ export class AgentFramework {
|
|
|
2955
4037
|
});
|
|
2956
4038
|
}
|
|
2957
4039
|
|
|
4040
|
+
/**
|
|
4041
|
+
* Handle the synthesized `sleep` / `wake` tools. `sleep` arms the gate's
|
|
4042
|
+
* suppression window, optionally announces in the sticky channel, and ends
|
|
4043
|
+
* the turn (the agent goes idle immediately). `wake` clears sleep.
|
|
4044
|
+
*/
|
|
4045
|
+
private dispatchSleepToolCall(agentName: string, call: ToolCall): void {
|
|
4046
|
+
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
4047
|
+
const gate = this.eventGate!;
|
|
4048
|
+
const input = (call.input ?? {}) as { seconds?: number; announce?: boolean; message?: string };
|
|
4049
|
+
|
|
4050
|
+
const finish = (result: ToolResult) => {
|
|
4051
|
+
this.emitTrace({ type: 'tool:completed', module: 'gate', tool: call.name, callId: call.id, durationMs: 0 });
|
|
4052
|
+
this.pushEvent({ type: 'tool-result', callId: call.id, agentName, moduleName: 'gate', result });
|
|
4053
|
+
};
|
|
4054
|
+
|
|
4055
|
+
if (call.name === 'wake') {
|
|
4056
|
+
const was = gate.clearSleep();
|
|
4057
|
+
finish({ success: true, data: { woke: was } });
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
const seconds = Number(input.seconds);
|
|
4062
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
4063
|
+
finish({ success: false, error: 'sleep: `seconds` must be a positive number', isError: true });
|
|
4064
|
+
return;
|
|
4065
|
+
}
|
|
4066
|
+
|
|
4067
|
+
const { until } = gate.setSleep(seconds, input.message);
|
|
4068
|
+
const announce = input.announce !== false; // default true
|
|
4069
|
+
const human =
|
|
4070
|
+
seconds >= 3600 ? `${(seconds / 3600).toFixed(1)}h`
|
|
4071
|
+
: seconds >= 60 ? `${Math.round(seconds / 60)}m`
|
|
4072
|
+
: `${Math.round(seconds)}s`;
|
|
4073
|
+
|
|
4074
|
+
// Announce in the sticky channel (best-effort; never blocks the result).
|
|
4075
|
+
if (announce && this.channelRegistry) {
|
|
4076
|
+
const text = input.message ?? `💤 Going quiet for ${human}. I'll still see messages, but won't respond until I wake.`;
|
|
4077
|
+
this.channelRegistry.routeSpeech(agentName, text).catch((err) => {
|
|
4078
|
+
console.error('[sleep] announce failed:', err instanceof Error ? err.message : err);
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
console.error(`[sleep] agent=${agentName} seconds=${seconds} announce=${announce} until=${new Date(until).toISOString()}`);
|
|
4083
|
+
// endTurn: the agent stops here and goes idle for the duration.
|
|
4084
|
+
finish({ success: true, data: { sleepingFor: human, until }, endTurn: true });
|
|
4085
|
+
}
|
|
4086
|
+
|
|
2958
4087
|
private dispatchGateToolCall(agentName: string, call: ToolCall): void {
|
|
2959
4088
|
this.emitTrace({ type: 'tool:started', module: 'gate', tool: call.name, callId: call.id, input: call.input });
|
|
2960
4089
|
const startTime = Date.now();
|