Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
7920e3b
fix(mcpl): a coalesced message is admitted as an uncoalesced one is; …
Oct 9, 2026
2ef45f3
fix(mcpl): a coalesce rejection is classified as the wire answers it;…
Oct 9, 2026
fff4e8e
fix(mcpl): a rejection line can't be forged by the sender's ids; the …
Oct 9, 2026
1c1ff7a
fix(mcpl): a coalesced item is admitted as an uncoalesced one is; eve…
Oct 11, 2026
98345e8
docs(mcpl): the validator's doc says where a deferred occurrence's co…
Oct 11, 2026
3fe13a1
fix(mcpl): a render error prints as a field, since the server words it
Oct 11, 2026
33c38d7
fix(mcpl): both lanes' converters take any object without throwing
Oct 12, 2026
9be6829
fix(mcpl): the channel lane's inference filter reads text that isn't …
Oct 12, 2026
51db73b
fix(mcpl): logValue escapes = and cuts a long value; the other host l…
Oct 13, 2026
7cdd624
fix(mcpl): a render is delivered only when its blocks convert to cont…
Oct 13, 2026
5c56755
fix(mcpl): an unregistered channel's refusals print at most one line …
Oct 13, 2026
0592d0f
docs(changelog): the #266 fragment describes the latest review round
Oct 13, 2026
dc446e8
fix(mcpl): one throttle holds back repeated refusal lines of a channe…
Oct 13, 2026
d1f402b
fix(mcpl): the coalesced batch's spawn-failure line prints its channe…
Oct 13, 2026
f43eefe
docs(mcpl): logValue, the render check and the push rejection trace s…
Oct 13, 2026
a91a664
test(mcpl): the render-check table covers an empty image uri and an o…
Oct 13, 2026
e74dd43
docs(changelog): the #266 fragment states the throttle's rule, the re…
Oct 13, 2026
f6bdbfa
fix(mcpl): a refused channel is recorded as its line prints it, and a…
Oct 13, 2026
dcc2d8f
docs(mcpl): logValue's doc and the #266 changelog fragment say what a…
Oct 13, 2026
553e241
fix(mcpl): logValue never throws, a value no reader can read printing…
Oct 13, 2026
0e7ff18
test(mcpl): a refused push's forged channel prints escaped, and pairs…
Oct 13, 2026
380987c
docs(changelog): the #266 fragment scopes the refusal lines' wording …
Oct 13, 2026
75626b7
docs(mcpl): logValue's cut is described by its rule, 255 units when t…
Oct 14, 2026
15caad4
fix(mcpl): a push refused for its channel names the channel in its tr…
Oct 14, 2026
64d9e08
test(mcpl): the render table pins the type guard, and the heartbeat l…
Oct 14, 2026
93a3b8f
docs(changelog): the #266 fragment names the push refusal's traced ch…
Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog.d/266-coalesced-content-parity.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- A coalesced MCPL message with inline images is admitted like any other message (#266). Coalesced `channels/incoming` and `push/event` items, and render results, were checked against a 1 MiB budget on their serialized content, inline base64 images included, which uncoalesced messages never had. Any message with a sizeable screenshot was refused as `coalesce_invalid`, never stored and never woke the agent, and discord-mcpl tags every message create with `coalesce`. An incoming coalesced item's content must be an array of objects with something visible in it (a retraction may be empty, and on the push lane so may a silent heartbeat), and within that it is admitted as an uncoalesced message is: a block the converters can't render, such as a video or an unknown type, becomes the same placeholder whether or not the message was coalesced. The converters of both lanes now take any object without throwing: a block's type, text, data, mimeType or uri that isn't a string is treated as absent, so a malformed block becomes a placeholder too (a text block, no text) instead of throwing in conversion, where the sender got no answer and a `channels/incoming` batch stopped before its later messages. An image or audio block that has data but no usable media type reads `[Image: no media type]` or `[Audio: no media type]` rather than claiming no data, and an image or audio block with a disposition is a reference only when its uri is a non-empty string. A `shouldTriggerInference` filter, and so the EventGate's content filter, reads on both lanes the text the agent's context gets, the converted text blocks joined with line breaks: on the channel lane, which read the message's own text blocks before, a block that converts to text (an unknown type's placeholder, an image with no data, a reference stub) is now part of what a filter matches, as it already was on the push lane, and text that isn't a string reads as empty. A render result, which the host delivers in the occurrence's place, gets every check a pushed payload gets (RFC-006 §5.2) except visible emptiness, since an empty render means nothing happened, and one more: each block must be one the converters show as itself (its text, or its image or audio source) or as a reference stub. A render with a block that would show as a placeholder falls back to the occurrence's own content, which a pushed payload doesn't have. The 4 KiB bound on `coalesce.data`, which the coalescer holds outside context, is unchanged.
Every coalesce rejection is now visible on the host as well as on the wire: as a `mcpl:channel-incoming-rejected` or `mcpl:push-event-rejected` trace with the error's message in `detail` and the failing `field` when the error names one, and as a line, unless it is a push refused for its channel within a minute of that channel's last line (below). A coalesce rejection's `[channel-incoming-rejected]` line names the server, channel, message id and failing field, and its `[push-event-rejected]` line names the server, feature set, event id and failing field; each ends with the error's message. When a channel's refusals were held back, the next line printed for it adds how many. The reason follows the answer the sender got: `coalesce-invalid` when the item was the sender's to fix (malformed, or a push to a channel it isn't permitted or hasn't registered), `coalesce-failed` when the host itself failed (coalescing unavailable, the host stopping or unable to make an acceptance durable, or an internal error). A `channels/incoming` message for an unregistered channel now gets a line too (`reason=unknown-channel`), coalesced or not. So that a server that never registered a busy channel doesn't print a line for every message in it, a channel's first refusal prints, and after that a refusal prints only once a minute has passed since the channel's last line, naming how many were held back since; a channel-scoped push refused because its channel isn't declared (-32023) or may not be used (-32017) prints the same way on its own lane, per channel, and its line and its trace name the channel. Every refusal is still answered and traced. The unknown-channel trace carries the message id, and its reason is now `unknown-channel`, the line's token (it was `unknown channel — not registered (§14.5)`). A render result the host refuses gets a `[push-render-failed]` line naming the server, subject, event id and failing field, and the occurrence's own content is delivered instead. A missing push `eventId` prints as `""` on every `[push-event-rejected]` line, and the push rejection trace carries `''`. The wire answers are unchanged.
The host log lines listed here now print the values a server chose (channel, message and event ids, feature set and field names, a tool's name and declared classes, an advertised capability's key, a discarded method's name) in a form a server can't use to add a line that reads as another host record, a word that reads as another field of the same line, or a `key=` that a search for that field finds before the real one. That covers every `[channel-incoming-rejected]` and `[push-event-rejected]` line, the existing empty-content, duplicate and feature-set rejections included; the `[channel-incoming-dropped]` and `[push-event-dropped]` backstops; the `[routeSpeech]` delivery and closed-locus lines; the `[inference-refusal]` line for a locus with no server; both conversation spawn-failure lines, the routed message's and the coalesced batch's; the `[heartbeat]` silent-wake line; the feature-set disabled line, the tool-class lines, the masked-capability line and the discarded-method line. A plain token (visible characters with no space, quote, backslash or `=`) prints as before. Anything else prints as a JSON string with every `=`, every space and every invisible character escaped (a line break, a terminal escape, a bidi control, a default-ignorable character such as a Hangul filler or a variation selector), so `JSON.parse` of it gives back exactly what was sent. A value longer than 256 UTF-16 units is cut instead: it prints, quoted, as its first 256 (255 when the 256th is a high surrogate) followed by `…(N)` with N its full length. A value that isn't a string prints as its JSON text, a non-finite number as `NaN` or `Infinity`. The feature-set rejection's reason, which names the feature set, is a field like any other: `reason="Unknown\u0020feature\u0020set:\u0020…"`. The `[inference-refusal]` line's locus, and a plain unknown tool class, now print without literal quotes. The host's own message at the end of a line is quoted and cut the same way but keeps its plain spaces and `=`.
33 changes: 24 additions & 9 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { REFUSAL_REACTIONS, REFUSAL_REACTION_FALLBACK } from './refusal-reaction
import { Agent } from './agent.js';
import { ModuleRegistry, isStateExistsError } from './module-registry.js';
import { McplServerRegistry } from './mcpl/server-registry.js';
import { logValue } from './mcpl/log-value.js';
import { FeatureSetManager } from './mcpl/feature-set-manager.js';
import { computeGrant, CapabilityGrant, expandAdvertisementShorthand } from './mcpl/capability-grant.js';
import { maskNegotiatedCapabilities } from './mcpl/capability-mask.js';
Expand Down Expand Up @@ -4793,7 +4794,7 @@ export class AgentFramework {
const server = serverId ? this.mcplServerRegistry?.getServer(serverId) : null;
if (!server) {
console.error(
`[inference-refusal] reaction skipped: no MCPL server for locus "${incoming.channelId}" (agent=${agentName})`,
`[inference-refusal] reaction skipped: no MCPL server for locus ${logValue(incoming.channelId)} (agent=${agentName})`,
);
return;
}
Expand Down Expand Up @@ -7100,7 +7101,7 @@ export class AgentFramework {
// Same backstop as the push lane: no row, no wake, no fork spawned for a
// message with nothing visible in it.
if (isVisiblyEmptyContent(event.content)) {
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${event.channelId} messageId=${event.messageId} reason=empty-content`);
console.error(`[channel-incoming-dropped] server=${event.serverId} channel=${logValue(event.channelId)} messageId=${logValue(event.messageId)} reason=empty-content`);
this.emitTrace({
type: 'mcpl:empty-content-dropped', lane: 'channel', serverId: event.serverId,
channelId: event.channelId, messageId: event.messageId, ...(event.eventId ? { eventId: event.eventId } : {}),
Expand Down Expand Up @@ -7286,7 +7287,7 @@ export class AgentFramework {
});
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error));
console.error(`Failed to spawn conversation agent for ${event.channelId}:`, err);
console.error(`Failed to spawn conversation agent for ${logValue(event.channelId)}:`, err);
this.emitTrace({
type: 'mcpl:conversation-spawn-failed',
channelId: event.channelId,
Expand Down Expand Up @@ -7642,7 +7643,21 @@ export class AgentFramework {
if (!server?.isConnected) throw new Error('server disconnected');
return server.sendPushRender(params);
},
audit: (record) => this.emitTrace({ type: 'mcpl:coalescing', ...record }),
audit: (record) => {
// A render result the host refused falls back to the occurrence's own
// content; say so on the host, with the server, the subject and the
// failing field, not only in the trace. The error is a field like the
// rest (logValue, spaces escaped), not a host-message tail: a server
// that answers push/render with an error chooses its message.
if (record.kind === 'render-failed') {
console.error(
`[push-render-failed] server=${logValue(record.serverId ?? '')} subject=${logValue(record.subject ?? '')} ` +
`eventId=${logValue(record.eventId ?? '')}${record.field !== undefined ? ` field=${logValue(record.field)}` : ''}: ` +
`${logValue(record.error ?? '')} (the occurrence's own content is delivered instead)`,
);
}
this.emitTrace({ type: 'mcpl:coalescing', ...record });
},
save: (snapshot) => this.scheduleCoalescingSnapshot(snapshot),
recordReceipt: (record) => {
// Written now, not at the throttled flush: an acknowledged acceptance
Expand Down Expand Up @@ -7749,7 +7764,7 @@ export class AgentFramework {
throw new CoalesceError('eventId', 'eventId is required with coalesce');
}
if (typeof message.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
validateCoalescedContent(message.content, undefined, { allowEmpty: message.coalesce.retract === true });
validateCoalescedContent(message.content, { allowEmpty: message.coalesce.retract === true });
const c = message.coalesce;
const result = await this.pushCoalescer.accept({
serverId,
Expand All @@ -7776,7 +7791,7 @@ export class AgentFramework {
validateCoalesceMember(params.coalesce, 'push');
if (typeof params.eventId !== 'string' || !params.eventId) throw new CoalesceError('eventId', 'eventId is required');
if (typeof params.timestamp !== 'string') throw new CoalesceError('timestamp', 'timestamp is required');
validateCoalescedContent(params.payload?.content, undefined, {
validateCoalescedContent(params.payload?.content, {
allowEmpty: params.coalesce.retract === true || isSilentHeartbeatMarker({
serverId, featureSet: params.featureSet, origin: params.origin, content: params.payload?.content,
}),
Expand Down Expand Up @@ -7865,7 +7880,7 @@ export class AgentFramework {
this.persistConversationRouterState();
return [decision.agentName];
} catch (err) {
console.error(`[coalescing] could not spawn a conversation agent for ${occ.scope.id}:`, err);
console.error(`[coalescing] could not spawn a conversation agent for ${logValue(occ.scope.id)}:`, err);
return [];
}
}
Expand Down Expand Up @@ -8073,7 +8088,7 @@ export class AgentFramework {
// model would wake to `[Continue]` or an older message re-presented as
// the newest, a wake with no visible cause.
if (isVisiblyEmptyContent(event.content) && !isSilentHeartbeatMarker(event)) {
console.error(`[push-event-dropped] server=${event.serverId} eventId=${event.eventId} reason=empty-content`);
console.error(`[push-event-dropped] server=${event.serverId} eventId=${logValue(event.eventId ?? '')} reason=empty-content`);
this.emitTrace({ type: 'mcpl:empty-content-dropped', lane: 'push', serverId: event.serverId, eventId: event.eventId });
return undefined;
}
Expand Down Expand Up @@ -8138,7 +8153,7 @@ export class AgentFramework {
const id = this.addMessage('user', content, metadata, { placement, bypassDeferralFor: event.assemblingFor });
this.emitTrace({ type: 'message:added', messageId: id, source: 'mcpl:push-event' });
} else {
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${event.eventId}`);
console.error(`[heartbeat] ${event.serverId}: accepted silent scheduled wake ${logValue(event.eventId ?? '')}`);
}

if (event.triggerInference) {
Expand Down
Loading
Loading