Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/fix-silent-drops-say-so.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
- An MCPL text block whose text isn't a string, which only a nonconformant server sends, now reaches the agent as `[Text not shown: the server's text block had no string text.]` instead of vanishing from its view or reading as `[object Object]`. That holds on the push and channel lanes, coalesced or not, in a `context/beforeInference` injection, and in a tool result, text-only or beside an image or a reference, where such a block sent the whole result to the agent as JSON, a reference's uri included. An MCPL tool error whose only text isn't a string now reports the placeholder as its error, where it read `[object Object]` for an object, the value itself for a number or `true`, its items joined by commas for an array, and `Tool call failed` for `null`, `false` or `0`. A `system` injection's text used to be joined into the system prompt as it was, which for an object with no string form threw while the request was built; a text-only tool result whose only block was such a text came back as an empty success. The placeholder still counts as no text when a message is checked for visible content, so a message holding only such blocks (or blank text) is still refused as empty, with the same answers as before, and a message whose blocks are numbers, strings or booleans is still admitted uncoalesced as its untyped placeholders. A `shouldTriggerInference` filter, and so the EventGate's content filter, now reads the placeholder where it read an empty line, as it already reads the converters' other placeholders.
- A `context/beforeInference` injection's content now converts as a pushed event's and a channel message's does: one converter serves all three. Before, an injection block whose `type` wasn't a string read as that value (`[unrecognized content block: 5]`, `[unrecognized content block: [object Object]]`), and one whose `type` was an object with no string form threw while it was converted, so the framework dropped every server's injections for that turn, with only a console line. An image's `data`, `mimeType` or `uri`, or an audio block's `data` or `mimeType`, that wasn't a string was passed on to the provider as the block's source, and a `uri` with a `disposition` was taken as a reference. An audio block without inline data showed its uri in its text: one that wasn't a string read as itself, and one with no string form threw, as a `type` did. Such fields now count as absent, and such blocks show the placeholders the other lanes show: `[unrecognized content block: untyped]`; `[Image: no data]` (where it read `[Image: missing data]`) or `[Image: no media type]`; and `[Audio: no data]` (where it read `[Audio: <uri>]` or `[Audio: missing data]`) or `[Audio: no media type]`. So an injection no longer writes an audio block's uri into the agent's text. An injection the host can't read is now left out with a console line naming its server, and the turn keeps every other injection: one holding a null block or content that isn't an array, a null injection, or one whose position has no string form, and likewise an answer whose injections aren't a list. Before, any of these dropped them all. An injection whose namespace can't be read as text, such as `{"toString": 1}`, is left out the same way. Before, a `beforeUser` or `afterUser` one failed the whole request, since context-manager writes the namespace into a participant's name as it builds the request, and a `system` one arrived, as context-manager never reads its namespace. Any other namespace that isn't a string, which §10.4 doesn't allow, still reaches the agent as it did: a `beforeUser` or `afterUser` injection's is written into its participant's name as its text, such as `system_context:5`, or `system_context:undefined` for an injection without one. The first such namespace from each server now gets a console line saying so. The line for an injection outside the server's grant now prints its position as one field, so a position holding a line break can't add a line of its own.
- The Discord module now gives the agent `[Attachment: <filename> (failed to fetch)]` for a text attachment whose download is answered with a status that isn't OK, as it already did when the download threw. Before, such an attachment was left out without a word, and a message with nothing else in it read `[Empty message]`. Either failure now also writes a console line naming the file, the message and the status or error, and an answer that isn't OK has its body cancelled, so its connection is released.
23 changes: 15 additions & 8 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ import { HookOrchestrator } from './mcpl/hook-orchestrator.js';
import { ToolLifecycleEmitter, parseToolObserveParams, type ScriptCallOrigin } from './mcpl/tool-lifecycle.js';
import { checkToolPattern, describeUnmatchedPattern, type PatternServer } from './mcpl/tool-pattern-check.js';
import { BUILTIN_TOOL_CLASSES, normalizeClassTable, parseDeclaredClasses, resolveToolClass, type EffectiveToolClass, type ToolClass, type ToolClassSource } from './mcpl/tool-classes.js';
import { PushHandler, convertBlock as convertPushBlock, type McplPushEvent } from './mcpl/push-handler.js';
import { PushHandler, type McplPushEvent } from './mcpl/push-handler.js';
import { convertBlock } from './mcpl/convert-block.js';
import {
PushCoalescer, CoalesceError, PUSH_COALESCING_SUPPORT, validateCoalesceMember, validateCoalescedContent,
coalescingSubjectKey, type AssemblyResult, type CoalescedOccurrence, type CoalescingPlacement, type CoalescingSnapshot,
type CoalescingReceiptRecord,
} from './mcpl/push-coalescer.js';
import type { ChannelIncomingMessage, ChannelIncomingMessageResult, McplContentBlock, PushEventResult } from './mcpl/types.js';
import { isSilentHeartbeatMarker, isVisiblyEmptyContent } from './mcpl/visible-content.js';
import { isSilentHeartbeatMarker, isVisiblyEmptyContent, NON_STRING_TEXT_PLACEHOLDER } from './mcpl/visible-content.js';
import { parseProsePrefix, parseHybridProsePrefix } from './mcpl/prose-grammar.js';
import { ProseStreamRouter } from './mcpl/prose-stream-router.js';
import { detectKnownToolWrapperProse } from './tool-wrapper-prose-guard.js';
Expand Down Expand Up @@ -9875,7 +9876,7 @@ export class AgentFramework {
messageId: identity.messageId ?? str(origin.messageId) ?? occ.eventId,
threadId: identity.threadId ?? str(origin.threadId),
author: identity.author ?? { id: str(origin.authorId) ?? '', name: str(origin.authorName) ?? str(origin.authorId) ?? 'Someone' },
content: materialized ? materialized.map(convertPushBlock) : push.content,
content: materialized ? materialized.map((block) => convertBlock(block, 'attachment on push event')) : push.content,
timestamp: occ.timestamp,
metadata: origin,
tags: push.tags,
Expand All @@ -9894,7 +9895,7 @@ export class AgentFramework {
...occ.event.event,
eventId: occ.eventId,
timestamp: occ.timestamp,
...(materialized ? { content: materialized.map(convertPushBlock) } : {}),
...(materialized ? { content: materialized.map((block) => convertBlock(block, 'attachment on push event')) } : {}),
coalescingSubject,
// §5.2: the materialized occurrence is consumed by the request being
// assembled; the wake that started it is not repeated.
Expand Down Expand Up @@ -14590,8 +14591,10 @@ export class AgentFramework {
blocks.push({ type: 'text', text: INLINE_WITHHELD_TEXT });
continue;
}
if (b.type === 'text' && typeof b.text === 'string') {
let text = b.text;
if (b.type === 'text') {
// A text that isn't a string shows as the converters' placeholder,
// rather than sending the whole array to the JSON fallback.
let text = typeof b.text === 'string' ? b.text : NON_STRING_TEXT_PLACEHOLDER;
if (maxChars && text.length > maxChars) {
text = safeSlice(text, 0, maxChars)
+ '\n\n[truncated — original was ' + text.length + ' chars'
Expand Down Expand Up @@ -17944,9 +17947,13 @@ export class AgentFramework {
// through so toMembraneToolResult can preserve image blocks natively.
// Text-only results still collapse to a joined string for backward
// compatibility with callers that expect data to be string-ish.
// A text block whose text isn't a string, which only a nonconformant
// server sends, shows as the converter's placeholder, where it used to
// vanish (null, 0, false) or read as String(text), "[object Object]".
const textContent = result.content
?.filter((c) => c.type === 'text' && c.text)
.map((c) => c.text!)
?.filter((c) => c.type === 'text')
.map((c) => (typeof c.text === 'string' ? c.text : NON_STRING_TEXT_PLACEHOLDER))
.filter((text) => text !== '')
.join('\n');
const hasNonText = result.content?.some((c) => c.type !== 'text');
const data = result.isError
Expand Down
93 changes: 5 additions & 88 deletions src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import type { ContentBlock } from '@animalabs/membrane';
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
import { convertBlock } from './convert-block.js';
import type { JsStore } from '@animalabs/chronicle';

import type {
Expand All @@ -30,15 +30,14 @@ import type {
ChannelsPublishParams,
ChannelsOpenResult,
ChannelHistoryRequest,
McplContentBlock,
} from './types.js';

import type { McplServerRegistry } from './server-registry.js';
import type { FeatureSetManager } from './feature-set-manager.js';
import type { ToolDefinition, ToolResult, ProcessEvent } from '../types/index.js';
import { expandCoreTags } from './tags.js';
import { coalesceRejectionReason, EmptyContentError, validateCoalescedContent } from './push-coalescer.js';
import { isVisiblyEmptyContent } from './visible-content.js';
import { isVisiblyEmptyConversion } from './visible-content.js';
import { logDetail, logValue } from './log-value.js';
import { RefusalLines } from './refusal-lines.js';
import { CapabilityGrant } from './capability-grant.js';
Expand Down Expand Up @@ -241,88 +240,6 @@ interface McplChannelIncomingEvent {
targetAgents?: string[];
}

// ============================================================================
// Content Conversion: McplContentBlock → membrane ContentBlock
// ============================================================================

/**
* Convert a single MCPL wire-format content block to a membrane ContentBlock.
*/
function convertBlock(block: McplContentBlock): ContentBlock {
switch (block.type) {
case 'text':
// Text that isn't a string is no text, as the request sanitizer and
// the visible-content check already read it.
return { type: 'text', text: typeof block.text === 'string' ? block.text : '' };

case 'image':
if (isInlineContradiction(block)) {
// RFC-005 vector 2: inline data claiming bulk disposition — fail
// closed, withhold the data (checked BEFORE the data branch).
return { type: 'text', text: INLINE_WITHHELD_TEXT };
}
if (isFilledString(block.uri) && block.disposition) {
// RFC-005 uri-form media with a disposition claim: stub, do not
// hand the URI to the provider or inline it.
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
}
if (isFilledString(block.data) && isFilledString(block.mimeType)) {
return {
type: 'image',
source: { type: 'base64', data: block.data, mediaType: block.mimeType },
} as ContentBlock;
}
if (isFilledString(block.uri)) {
return {
type: 'image',
source: { type: 'url', url: block.uri },
} as ContentBlock;
}
return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };

case 'audio':
if (isInlineContradiction(block)) {
// RFC-005 vector 2: inline data claiming bulk disposition — fail
// closed, withhold the data (checked BEFORE the data branch).
return { type: 'text', text: INLINE_WITHHELD_TEXT };
}
if (isFilledString(block.uri) && block.disposition) {
// RFC-005 uri-form media with a disposition claim: stub, do not
// hand the URI to the provider or inline it.
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };
}
if (isFilledString(block.data) && isFilledString(block.mimeType)) {
return {
type: 'audio',
source: { type: 'base64', data: block.data, mediaType: block.mimeType },
} as ContentBlock;
}
return { type: 'text', text: isFilledString(block.data) ? '[Audio: no media type]' : '[Audio: no data]' };

case 'resource':
// RFC-005: reference blocks become bounded stubs — never raw URIs
// (a signed URL is a bearer credential that looks like a location).
return { type: 'text', text: referenceStubOrNull(block, 'attachment on channel message') ?? '[reference]' };

default: {
// Unknown wire block types previously fell off the exhaustive switch
// and propagated `undefined` into ContentBlock[]. Fail visibly. A type
// that isn't a string is no type, and is never converted to one: an
// object can throw when it is.
const type: unknown = (block as { type?: unknown }).type;
return { type: 'text', text: `[unrecognized content block: ${typeof type === 'string' ? type : 'untyped'}]` };
}
}
}

/** A string with something in it: the only data, mimeType or uri a block's
* source is built from, and the only uri that makes it a reference. Anything
* else a sender puts there leaves the block without it, so conversion never
* throws and never copies a non-string. */
function isFilledString(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}

// ============================================================================
// Channel Tool Definitions
// ============================================================================
Expand Down Expand Up @@ -1031,8 +948,8 @@ export class ChannelRegistry {
}
}
if (message.tags) message.tags = expandCoreTags(message.tags);
const convertedContent: ContentBlock[] = message.content.map(convertBlock);
if (!emptyAllowed && isVisiblyEmptyContent(convertedContent)) {
const convertedContent: ContentBlock[] = message.content.map((block) => convertBlock(block, 'attachment on channel message'));
if (!emptyAllowed && isVisiblyEmptyConversion(message.content, convertedContent)) {
rejectEmpty();
continue;
}
Expand Down Expand Up @@ -1060,7 +977,7 @@ export class ChannelRegistry {
if (this.shouldTriggerInference) {
// The text the agent's context gets, as the push lane's filter reads
// it: the converted text blocks, a placeholder or reference stub
// among them, and a text block's non-string text as empty text.
// among them, NON_STRING_TEXT_PLACEHOLDER included.
const textContent = convertedContent
.filter((b): b is ContentBlock & { type: 'text' } => b.type === 'text')
.map((b) => b.text)
Expand Down
94 changes: 94 additions & 0 deletions src/mcpl/convert-block.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* MCPL wire content to membrane content: the one conversion every lane uses,
* for pushed events, channel messages and context injections alike, so a rule
* about a malformed block holds on all three.
*/

import type { ContentBlock } from '@animalabs/membrane';
import { INLINE_WITHHELD_TEXT, isInlineContradiction, referenceStubOrNull } from './references.js';
import { NON_STRING_TEXT_PLACEHOLDER } from './visible-content.js';
import type { McplContentBlock } from './types.js';

/**
* Convert a single MCPL wire-format content block to a membrane ContentBlock.
* It takes any block but null or undefined without throwing (those two throw,
* for the caller to refuse or leave out): a type, data, mimeType or uri that
* isn't a string counts as absent, and a text block whose text isn't a string
* shows as NON_STRING_TEXT_PLACEHOLDER. `provenance` is what a reference stub
* says the reference came on, such as 'attachment on push event'.
*/
export function convertBlock(block: McplContentBlock, provenance: string): ContentBlock {
switch (block.type) {
case 'text':
// Text that isn't a string can't be shown, so the agent reads that a
// block was there; the emptiness checks still count it as no text
// (isVisiblyEmptyConversion), as the request sanitizer would.
return { type: 'text', text: typeof block.text === 'string' ? block.text : NON_STRING_TEXT_PLACEHOLDER };

case 'image':
if (isInlineContradiction(block)) {
// RFC-005 vector 2: inline data claiming bulk disposition — fail
// closed, withhold the data (checked BEFORE the data branch).
return { type: 'text', text: INLINE_WITHHELD_TEXT };
}
if (isFilledString(block.uri) && block.disposition) {
// RFC-005 uri-form media with a disposition claim: stub, do not
// hand the URI to the provider or inline it.
return { type: 'text', text: referenceStubOrNull(block, provenance) ?? '[reference]' };
}
if (isFilledString(block.data) && isFilledString(block.mimeType)) {
return {
type: 'image',
source: { type: 'base64', data: block.data, mediaType: block.mimeType },
} as ContentBlock;
}
if (isFilledString(block.uri)) {
return {
type: 'image',
source: { type: 'url', url: block.uri },
} as ContentBlock;
}
return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };

case 'audio':
if (isInlineContradiction(block)) {
// RFC-005 vector 2: inline data claiming bulk disposition — fail
// closed, withhold the data (checked BEFORE the data branch).
return { type: 'text', text: INLINE_WITHHELD_TEXT };
}
if (isFilledString(block.uri) && block.disposition) {
// RFC-005 uri-form media with a disposition claim: stub, do not
// hand the URI to the provider or inline it.
return { type: 'text', text: referenceStubOrNull(block, provenance) ?? '[reference]' };
}
if (isFilledString(block.data) && isFilledString(block.mimeType)) {
return {
type: 'audio',
source: { type: 'base64', data: block.data, mediaType: block.mimeType },
} as ContentBlock;
}
return { type: 'text', text: isFilledString(block.data) ? '[Audio: no media type]' : '[Audio: no data]' };

case 'resource':
// RFC-005: reference blocks become bounded stubs — never raw URIs
// (a signed URL is a bearer credential that looks like a location).
return { type: 'text', text: referenceStubOrNull(block, provenance) ?? '[reference]' };

default: {
// Unknown wire block types previously fell off the exhaustive switch
// and propagated `undefined` into ContentBlock[]. Fail visibly. A type
// that isn't a string is no type, and is never converted to one: an
// object can throw when it is.
const type: unknown = (block as { type?: unknown }).type;
return { type: 'text', text: `[unrecognized content block: ${typeof type === 'string' ? type : 'untyped'}]` };
}
}
}

/** A string with something in it: the only data, mimeType or uri a block's
* source is built from, and the only uri that makes it a reference. Anything
* else a sender puts there leaves the block without it, so conversion never
* throws and never copies a non-string. */
function isFilledString(value: unknown): value is string {
return typeof value === 'string' && value !== '';
}
Loading
Loading