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
11 changes: 11 additions & 0 deletions changelog.d/mcpl-agent-words.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
- The MCPL texts an agent reads for a failed request, a refused tool call and a block the host can't show now say what happened, whether anything took effect, and what works from there, or that nothing does. Method names, request ids, JSON-RPC codes, close details and field names leave them. The ones an operator needs go to the host's log.
- **A failed MCPL request:**
- A connection that closes with a call pending reads `The connection to the "<server>" server closed before it answered, so whether the request took effect is unknown.` It then says whether this host is reconnecting, where it read `MCPL server "<server>" disconnected unexpectedly (code=…, signal=…, reason=…) while awaiting <method> (id=N)`.
- The other texts change the same way: a close by this host, a request on a closed connection or one the transport couldn't write, a server's error answer (in the server's own words, or, with none, saying the outcome is unknown) and a timeout.
- The log gains a line for a close with requests pending, with the close's code, signal and reason, whether this host closed it, and the requests left unanswered; one for a server's error answer, with its method, id and code; one for a timeout, with its method and id; and one for a request the transport couldn't write, with its method, id and the transport's words.
- **The framework's MCPL tool calls:**
- A call to a server missing from the registry, or to a tool its policy denies, says it didn't run and whether anything can change that, in the same words for the model's call and a script's. One made while this host is connecting to the server, in a runtime connect or a restart's, says so and to try again in a moment.
- An error result with no text, or only whitespace, reads as one whose effect is unknown, where it read `Tool call failed`, or the blank text.
- A conversation's `channel_open` is told where to reply.
- **Blocks the host can't show** read as what they were and why: `[Image not shown: …]`, `[Audio not shown: …]`, `[Reference not shown: …]`, `[Content not shown: …]`, and the text marker `[Text not shown: a text part arrived without readable text.]`. They had read `[Image: no data]`, `[unrecognized content block: …]`, `[inline content withheld: …]` and `[Text not shown: the server's text block had no string text.]`. An image or audio block with an empty link and no data reads as the no-data marker on the tool-result lane too.
- **A reference's expiry and missing server** read `expired`, `treated as expired, since its expiry time can't be read`, `treated as expired, since its expiry time isn't in a form this host accepts` and `not tied to any server, so there's nowhere to fetch it from`. They had read `expired: its expiresAt has passed`, `… (fail closed)` and `unbound: no server is recorded to fetch it from`.
1 change: 1 addition & 0 deletions changelog.d/mcpl-connect-in-flight.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- A runtime connect, a restart or a disconnect of an MCPL server whose connect is still in flight is now refused before it changes anything, with `MCPL server "<id>" has a connect in flight; try again once it settles`. Nothing stops a connect in flight. A second connect went ahead beside it, and whichever settled last replaced the other in the registry without closing it. A disconnect removed the server's config and tool prefix, and the connect then registered the server without them, where no call reached it and only another disconnect cleared it. A restart begins with that disconnect.
129 changes: 102 additions & 27 deletions src/framework.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,43 @@ import { splitProseRuns, withoutRoundsProse } from './prose-segments.js';
import { cumulativeDelta } from './usage-accounting.js';
import { stampThinkingTokenEstimates } from './thinking-token-stamp.js';

/** An MCPL tool call refused because its server isn't in the registry: one
Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Restart looks permanent

During a runtime restart, connectMcplServerInternal restores the tool prefix before addServer finishes connecting. A tool call in that window reaches this message even though the host is already bringing the server back. Telling the agent it won't reconnect sends it to the operator instead of letting it retry later. Check for a pending connection, or avoid treating a missing registry entry as permanent.

Knowledge Base Used: MCPL transport and server registration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 144-145

Comment:
**Restart looks permanent**

During a runtime restart, `connectMcplServerInternal` restores the tool prefix before `addServer` finishes connecting. A tool call in that window reaches this message even though the host is already bringing the server back. Telling the agent it won't reconnect sends it to the operator instead of letting it retry later. Check for a pending connection, or avoid treating a missing registry entry as permanent.

**Knowledge Base Used:** [MCPL transport and server registration](https://app.greptile.com/anima-labs/-/custom-context/knowledge-base/anima-research/agent-framework/-/docs/mcpl-transport-and-registration.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is right, and it's fixed at 6975a5bf.

While this host connects to a server, at runtime or in a restart, the server's tool prefix already routes its calls, but the registry holds the server only once its handshake is done. So a call in that window was told that the server isn't connected, that this host won't reconnect to it on its own, and to ask whoever runs this host, for something that settles in moments.

The framework now keeps the ids whose connect is in flight. Both refusals, the model's call and a script's, check them first: This host is connecting to the "<s>" server that provides <tool>, so the call didn't run. Try again in a moment. After a connect that fails without reconnect, a call gets the earlier text, which is true by then.

The same claim stood in a restart's close. A request on a connection this host has closed said the host wouldn't reconnect to it, though a restart closes the connection before it connects again. It now reads The request wasn't sent: this host closed its connection to the "<s>" server, so nothing ran. If calling it again fails, ask whoever runs this host.

Written by Cordelia-2434.

— Lottie-2661

* closed for good, one removed, or one whose connect failed without
* reconnect, which this host doesn't bring back on its own. A server that's
* reconnecting stays registered, as does the stub a failed connect with
* reconnect leaves, and its closed connection answers the call instead. One
* this host is connecting to isn't registered yet, and
* mcplServerConnectingText answers for it. */
function mcplServerGoneText(serverId: string, toolName: string): string {
return `The "${serverId}" server that provides ${toolName} isn't connected, and this host won't reconnect to it on its own, ` +
"so the call didn't run. If you need it, ask whoever runs this host.";
}

/** An MCPL tool call refused while this host connects to its server, in a
* runtime connect or a restart's: the server's prefix routes calls to it
* before the registry holds it, which it does once the handshake is done. */
function mcplServerConnectingText(serverId: string, toolName: string): string {
return `This host is connecting to the "${serverId}" server that provides ${toolName}, so the call didn't run. ` +
'Try again in a moment.';
}

/** An MCPL tool call its server's tool policy denies. The policy hides the
* tool from the list it's offered in, so this is a call to a tool the agent
* wasn't offered. */
function mcplToolDeniedText(serverId: string, toolName: string): string {
return `${toolName} isn't allowed by this host's policy for the "${serverId}" server, so the call didn't run, ` +
"and calling it again won't change that. If you need it, ask whoever runs this host.";
}

/** A runtime connect, restart or disconnect refused because a connect of the
* server is in flight (mcplConnecting). The registry takes a server only once
* its handshake is done, and nothing can stop that connect meanwhile, so it
* would register the server after whatever ran beside it. Whoever asked reads
* it: an operator, or an agent through a host's tool that passes it on. */
function mcplConnectInFlightError(serverId: string): Error {
return new Error(`MCPL server "${serverId}" has a connect in flight; try again once it settles`);
}

/** Detect a supported image media type from magic bytes (the model API
* rejects mislabeled media types, so trust bytes over extensions).
* Returns undefined for non-image content. */
Expand Down Expand Up @@ -340,13 +377,23 @@ function mcplResultText(content: McpToolResultContent[] | undefined): string {

/**
* An MCPL tools/call result the server marked `isError`, as a failure: its
* text is the error, or `Tool call failed` when it has none, and its other
* blocks aren't kept. The model's dispatch and an in-process caller
* (`executeToolCall`, `ModuleContext.callTool`) both take a server's error
* from here, so it reads the same on each path.
* text is the error, and with none, or only whitespace, as a server's error
* answer reads it, the error says the server gave no words and that whether
* the call did anything is unknown. Its other blocks aren't kept. The model's
* dispatch and an in-process caller (`executeToolCall`,
* `ModuleContext.callTool`) both take a server's error from here, so it reads
* the same on each path. `callName` is the tool's name as it was called.
*/
function mcplToolFailure(content: McpToolResultContent[] | undefined): ToolResult {
return { success: false, error: mcplResultText(content) || 'Tool call failed', isError: true };
function mcplToolFailure(content: McpToolResultContent[] | undefined, serverId: string, callName: string): ToolResult {
const said = mcplResultText(content);
return {
success: false,
error: said.trim()
? said
: `The "${serverId}" server reported an error for ${callName} without saying what went wrong. ` +
'Whether it did anything is unknown: check before calling it again.',
isError: true,
};
}

/**
Expand Down Expand Up @@ -2358,6 +2405,13 @@ export class AgentFramework {
private toolPatternKnownPrefixes: Map<string, string> = new Map();
/** Maps serverId → McplServerConfig for prefix lookup. */
private mcplServerConfigs: Map<string, import('./mcpl/types.js').McplServerConfig> = new Map();
/** Servers whose connect is in flight (connectMcplServerInternal), from the
* registry's handshake until it settles: a call their prefix routes then
* finds no server registered, and is told this host is connecting to it.
* One mark per id: the registry holds one connection per id, and a
* second connect, a restart or a disconnect of an id whose connect is in
* flight is refused (refuseMcplConnect, runDisconnectMcplServer). */
private mcplConnecting: Set<string> = new Set();
/** Host capabilities advertised during the MCP handshake — stored so servers
* can be connected at runtime (connectMcplServer) after initialization. */
private mcplHostCapabilities: McplHostCapabilities | null = null;
Expand Down Expand Up @@ -17677,22 +17731,18 @@ export class AgentFramework {
}
const server = this.mcplServerRegistry.getServer(serverId);
if (!server) {
return { success: false, error: `MCPL server ${serverId} not found`, isError: true };
return { success: false, error: this.mcplServerAbsentText(serverId, call.name), isError: true };
}
const toolName = call.name.slice(prefix.length + 2); // Strip prefix + '--'
if (!isToolAllowed(toolName, this.mcplServerConfigs.get(serverId))) {
return {
success: false,
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
isError: true,
};
return { success: false, error: mcplToolDeniedText(serverId, call.name), isError: true };
}
try {
const result = await server.sendToolsCall(toolName, call.input as Record<string, unknown>);
// A server's error is a failure here too, as the model's dispatch
// reads it. A success is the server's own blocks, for the caller to
// use in process.
if (result.isError) return mcplToolFailure(result.content);
if (result.isError) return mcplToolFailure(result.content, serverId, call.name);
// RFC-005 §6.2: register the result's references with this server, so
// a stub later made of them is bound and fetch_reference can fetch
// them through this server's address, where it has one. Nothing is
Expand Down Expand Up @@ -21291,7 +21341,18 @@ export class AgentFramework {
}),
});
}
const connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities);
// The prefix above routes this server's tool calls already, and addServer
// registers the server only once its handshake is done, so until it
// settles a call is told this host is connecting to it. A connect that
// fails without reconnect leaves it unregistered, and a call is told it's
// gone; with reconnect, the stub it leaves is registered and answers.
this.mcplConnecting.add(config.id);
let connection: McplServerConnection;
try {
connection = await this.mcplServerRegistry.addServer(connectionConfig, this.mcplHostCapabilities);
} finally {
this.mcplConnecting.delete(config.id);
}

// Wire listeners before either startup staging or the runtime global gate
// releases control traffic needed for registration and marker service.
Expand Down Expand Up @@ -21835,16 +21896,22 @@ export class AgentFramework {

/**
* Refuse a runtime connect, or a restart, before it writes anything: an id
* already registered, or a tool prefix that equals a module name or another
* server's prefix, or meets another server's (prefixesMeet, #265). create()
* applies the same prefix rules at startup. The connecting id's own entries
* don't count, nor, for a restart, those of the server it replaces
* (`replacing`), which it disconnects first.
* whose connect is in flight or that's already registered, or a tool prefix
* that equals a module name or another server's prefix, or meets another
* server's (prefixesMeet, #265). create() applies the same prefix rules at
* startup. The connecting id's own entries don't count, nor, for a restart,
* those of the server it replaces (`replacing`), which it disconnects first.
*/
private refuseMcplConnect(
config: import('./mcpl/types.js').McplServerConfig,
replacing?: string,
): void {
// A connect in flight isn't registered until its handshake is done, so
// the registry's check below misses it. Its id is taken whether this
// connects it or a restart replaces it: the restart's disconnect can't
// stop a connect in flight, which would register the server after it.
const inFlight = [config.id, replacing].find((id) => id !== undefined && this.mcplConnecting.has(id));
if (inFlight !== undefined) throw mcplConnectInFlightError(inFlight);
// The registry refuses a second server under one id too, but only once
// the connect has written its config and prefix over the live server's.
if (config.id !== replacing && this.mcplServerRegistry?.getServer(config.id)) {
Expand Down Expand Up @@ -21884,6 +21951,10 @@ export class AgentFramework {
if (!this.mcplServerRegistry) {
throw new Error('MCPL subsystem is not initialized');
}
// A connect in flight would register the server after this disconnect,
// with its config and prefix gone, where only another disconnect reaches
// it; refused as a second connect of it is (refuseMcplConnect).
if (this.mcplConnecting.has(id)) throw mcplConnectInFlightError(id);
const config = this.mcplServerConfigs.get(id);
const oldToolNames = new Set(this.mcplTools.map(t => t.name));

Expand Down Expand Up @@ -22850,6 +22921,14 @@ export class AgentFramework {
}
}

/** The refusal of an MCPL tool call whose server isn't in the registry, for
* the model's call and a script's alike. */
private mcplServerAbsentText(serverId: string, toolName: string): string {
return this.mcplConnecting.has(serverId)
? mcplServerConnectingText(serverId, toolName)
: mcplServerGoneText(serverId, toolName);
}

/**
* Dispatch a tool call to an MCPL server.
* Strips the configured toolPrefix and routes to the server.
Expand All @@ -22867,7 +22946,7 @@ export class AgentFramework {
toolName: call.name,
agentName,
moduleName: `mcpl:${serverId}`,
result: { success: false, error: `MCPL server not found: ${serverId}`, isError: true },
result: { success: false, error: this.mcplServerAbsentText(serverId, call.name), isError: true },
});
return;
}
Expand All @@ -22882,11 +22961,7 @@ export class AgentFramework {
toolName: call.name,
agentName,
moduleName: `mcpl:${serverId}`,
result: {
success: false,
error: `Tool '${call.name}' is not permitted by this server's tool policy.`,
isError: true,
},
result: { success: false, error: mcplToolDeniedText(serverId, call.name), isError: true },
});
return;
}
Expand Down Expand Up @@ -22964,7 +23039,7 @@ export class AgentFramework {
// string for backward compatibility with callers that expect data to
// be string-ish.
const converted: ToolResult = result.isError
? mcplToolFailure(result.content)
? mcplToolFailure(result.content, serverId, call.name)
: {
success: true,
data: result.content?.some((c) => c.type !== 'text')
Expand Down Expand Up @@ -23097,7 +23172,7 @@ export class AgentFramework {
};

if (call.name === 'channel_open') {
reject(`This conversation is bound to channel ${home}; conversation agents cannot open channels.`);
reject(`This conversation is bound to channel "${home}", so it can't open other channels, and nothing was opened. Reply in "${home}".`);
return;
}
if (call.name === 'channel_publish' || call.name === 'channel_close') {
Expand Down
7 changes: 6 additions & 1 deletion src/mcpl/channel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,12 @@ export interface PublishOutcome {
* a failed request this way.
*/
function publishErrorOutcome(err: unknown, destination: PublishDestination, at: number): PublishOutcome {
const message = connectorText(err instanceof Error ? err.message : String(err));
// This host's own words for a request that wasn't sent or wasn't answered
// are sentences, and every composition of the reason adds its own ending,
// so one final period comes off them. A server's error answer keeps its
// words as they came.
const text = connectorText(err instanceof Error ? err.message : String(err));
const message = err instanceof McplRequestError && err.outcome !== 'error-response' ? text.replace(/(?<!\.)\.$/, '') : text;
const notSent = err instanceof McplRequestError && err.outcome === 'not-sent';
const detail = err instanceof McplRequestError ? err.data : undefined;
return {
Expand Down
18 changes: 13 additions & 5 deletions src/mcpl/convert-block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
*/

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

Expand Down Expand Up @@ -51,7 +60,7 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
source: { type: 'url', url: block.uri },
} as ContentBlock;
}
return { type: 'text', text: isFilledString(block.data) ? '[Image: no media type]' : '[Image: no data]' };
return { type: 'text', text: isFilledString(block.data) ? IMAGE_NO_MEDIA_TYPE_TEXT : IMAGE_NO_DATA_TEXT };

case 'audio':
if (isInlineContradiction(block)) {
Expand All @@ -70,7 +79,7 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
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]' };
return { type: 'text', text: isFilledString(block.data) ? AUDIO_NO_MEDIA_TYPE_TEXT : AUDIO_NO_DATA_TEXT };

case 'resource':
// RFC-005: reference blocks become bounded stubs — never raw URIs
Expand All @@ -82,8 +91,7 @@ export function convertBlock(block: McplContentBlock, provenance: string, server
// 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'}]` };
return { type: 'text', text: unknownBlockText(block) };
}
}
}
Expand Down
Loading
Loading