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
23 changes: 23 additions & 0 deletions docs/tui-capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,26 @@ existing diagnostic-counts projection; raw error text, stacks and session IDs
are not added to the uploaded ZIP. Offline tests cover persisted tool histories,
archives, concurrent parent output, side-session cleanup and local diagnostics;
this does not establish native-terminal or live-model acceptance.

## Select a plugin for a message

Type `@` in the Composer to search files and installed, enabled plugins. Plugin
candidates show their source so packages with the same display name can be
selected independently. Choose a plugin with Tab or Enter, then describe the task.
The Composer shows `@Name` and retains the plugin identity through editing, undo,
prompt history, queued-message recovery, saved drafts, and `/edit` after a
message is sent. Ctrl+C clearing/restoration and external-editor edits retain
unchanged plugin bindings. If external edits make duplicate labels ambiguous,
reselect those plugins in the Composer. Displayed messages remain readable; Runtime retains
the original input separately when needed to recover the plugin identity for editing.

Selection applies to that message. Runtime checks the plugin's effective Skills,
MCP tools, and App tools again for the turn and asks the Agent to prefer relevant
capabilities. Selecting a plugin does not install or enable it. An unavailable
selection is reported to the Agent rather than redirected to a same-named package.

Exec and ACP text prompts can use the durable linked form, for example
`[@Notes](plugin://notes%40local) summarize these files`. The ID is the package
name plus its `local` or `official` source; display labels do not determine the
selection. Legacy whitespace-delimited `@package-name` text remains supported
when it identifies exactly one effective plugin.
3 changes: 2 additions & 1 deletion packages/agent-modules/system-reminder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"@mavis/config": "workspace:^",
"@types/node": "^20",
"typescript": "^5",
"vitest": "^2"
"vitest": "^2",
"@mavis/shared": "workspace:^"
},
"private": true,
"license": "MIT"
Expand Down
72 changes: 60 additions & 12 deletions packages/agent-modules/system-reminder/src/plugin-reference.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { parsePluginMentions } from '@mavis/shared/plugin-mention';

export interface EffectivePluginToolGroup {
readonly source: string;
readonly tools: readonly string[];
/** Deferred App tools must be discovered before they can be invoked. */
/** Deferred tools must be discovered before they can be invoked. */
readonly access?: 'direct' | 'tool_search';
}

export interface EffectivePluginCapabilityInventory {
readonly name: string;
readonly pluginId?: string;
readonly appTools: readonly EffectivePluginToolGroup[];
readonly mcpTools: readonly EffectivePluginToolGroup[];
readonly skills: readonly string[];
Expand All @@ -33,7 +36,7 @@ export function detectPluginReferencesForMessages<T extends EffectivePluginCapab

for (const text of texts) {
for (const match of detectReferencesInText(text, plugins)) {
const key = normalizedName(match.name);
const key = match.pluginId ?? normalizedName(match.name);
if (seen.has(key)) continue;
seen.add(key);
selected.push(match);
Expand All @@ -45,11 +48,12 @@ export function detectPluginReferencesForMessages<T extends EffectivePluginCapab
/** Render one self-contained reminder for every Plugin selected in this turn. */
export function buildPluginReferenceReminder(
plugins: readonly EffectivePluginCapabilityInventory[],
unavailablePluginIds: readonly string[] = [],
): string | undefined {
if (plugins.length === 0) return undefined;
const blocks = plugins.map((plugin) => {
if (plugins.length === 0 && unavailablePluginIds.length === 0) return undefined;
const blocks = plugins.slice(0, 8).map((plugin) => {
const name = inlineCode(plugin.name);
return [
const detail = [
`<selected-plugin name="${escapeXml(plugin.name)}">`,
`Referenced as \`@${name}\`.`,
'',
Expand All @@ -66,20 +70,46 @@ export function buildPluginReferenceReminder(
'</plugin-skills>',
'</selected-plugin>',
].join('\n');
return detail.length <= 4096
? detail
: `<selected-plugin name="${escapeXml(plugin.name).slice(0, 512)}">\nCapability details omitted to fit the context limit. Use this Plugin's tool provenance and Skill namespace in the available catalogs.\n</selected-plugin>`;
});

const bounded: string[] = [];
let remaining = 12_288;
let omitted = plugins.length > 8 || unavailablePluginIds.length > 8;
for (const block of [
...blocks,
...unavailablePluginIds
.slice(0, 8)
.map(
(id) =>
`Selected Plugin \`${inlineCode(id)}\` is unavailable for this turn. Tell the user it could not be used; do not substitute a same-named Plugin or enable/install it automatically.`,
),
]) {
if (block.length + 2 > remaining) {
omitted = true;
continue;
}
bounded.push(block);
remaining -= block.length + 2;
}

return [
'<system-reminder>',
'The user explicitly selected the following Plugin capabilities for this request.',
'Prefer them when relevant; other tools remain available if needed.',
'',
blocks.join('\n\n'),
bounded.join('\n\n'),
...(omitted ? ['Additional selected capabilities omitted to fit the context limit.'] : []),
'',
'Only the capabilities listed above are effective for this turn.',
'The listed capabilities are drawn from the effective inventory for this turn; lists may be truncated.',
'Do not invent or claim unavailable Plugin capabilities.',
...(plugins.some((plugin) => plugin.appTools.some((group) => group.access === 'tool_search'))
...(plugins.some((plugin) =>
[...plugin.appTools, ...plugin.mcpTools].some((group) => group.access === 'tool_search'),
)
? [
'For App tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.',
'For tools marked `via tool_search + mcp_invoke`, discover the exact tool with `tool_search` before calling it through `mcp_invoke`.',
]
: []),
'Before following a listed Skill, call the `skill` tool with its exact name.',
Expand All @@ -91,6 +121,7 @@ export function buildPluginReferenceReminder(
function formatAppToolGroups(groups: readonly EffectivePluginToolGroup[]): string {
if (groups.length === 0) return 'none';
return [...groups]
.slice(0, 16)
.sort((left, right) => {
const sourceOrder = normalizedName(left.source).localeCompare(normalizedName(right.source));
if (sourceOrder !== 0) return sourceOrder;
Expand All @@ -108,11 +139,24 @@ function detectReferencesInText<T extends EffectivePluginCapabilityInventory>(
text: string,
plugins: readonly T[],
): T[] {
const normalizedText = text.normalize('NFKC');
const linked = parsePluginMentions(text);
let plainText = text;
for (const mention of [...linked].reverse())
plainText = `${plainText.slice(0, mention.start)}${' '.repeat(mention.end - mention.start)}${plainText.slice(mention.end)}`;
const normalizedText = plainText.normalize('NFKC');
const matches: Array<{ index: number; plugin: T }> = [];
for (const plugin of plugins) {
const explicit = linked.find((mention) => mention.pluginId === plugin.pluginId);
if (explicit) {
matches.push({ index: explicit.start, plugin });
continue;
}
const name = normalizedName(plugin.name);
if (!name) continue;
if (
!name ||
plugins.filter((candidate) => normalizedName(candidate.name) === name).length !== 1
)
continue;
const pattern = new RegExp(`(^|\\s)@${escapeRegExp(name)}(?=\\s|$)`, 'giu');
const match = pattern.exec(normalizedText);
if (!match) continue;
Expand All @@ -128,16 +172,19 @@ function formatToolGroups(
): string {
if (groups.length === 0) return 'none';
return [...groups]
.slice(0, 16)
.sort((left, right) => normalizedName(left.source).localeCompare(normalizedName(right.source)))
.map((group) => {
const tools = formatToolNames(group.tools);
return `- ${label} \`${inlineCode(group.source)}\`: ${tools || 'none'}`;
const access = group.access === 'tool_search' ? ' via `tool_search` + `mcp_invoke`' : '';
return `- ${label} \`${inlineCode(group.source)}\`${access}: ${tools || 'none'}`;
})
.join('\n');
}

function formatToolNames(tools: readonly string[]): string {
return [...new Set(tools)]
.slice(0, 32)
.sort((left, right) => normalizedName(left).localeCompare(normalizedName(right)))
.map((tool) => `\`${inlineCode(tool)}\``)
.join(', ');
Expand All @@ -146,6 +193,7 @@ function formatToolNames(tools: readonly string[]): string {
function formatSkills(skills: readonly string[]): string {
if (skills.length === 0) return 'none';
return [...new Set(skills)]
.slice(0, 32)
.sort((left, right) => normalizedName(left).localeCompare(normalizedName(right)))
.map((skill) => `- \`${inlineCode(skill)}\``)
.join('\n');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import {
} from "./session-stream-delivery.js";

export interface ConversationSendMessageRequest extends SendMessageReq {
/** Readable projection supplied by the process-local composer; content retains transport identity. */
readonly displayContent?: string;
/** Process-local caller's Unix-ms deadline; its cancellation owner still enforces it. */
readonly executionDeadlineAtMs?: number;
/** Process-local opt-in; emits metadata only, never provider payloads. */
Expand Down Expand Up @@ -575,6 +577,9 @@ async function toDirectSendInput(
input: toAgentHostUserInput(req, text, attachments),
...executionOptions,
provenance: messageProvenance(req),
...(req.displayContent !== undefined
? { displayContent: req.displayContent }
: {}),
...(materialized.displayAttachments.length > 0
? { displayAttachments: materialized.displayAttachments }
: {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface DirectSendInput {
readonly outputContract?: SubmitTurnSubmission['outputContract'];
readonly executionDeadlineAtMs?: SubmitTurnSubmission['executionDeadlineAtMs'];
readonly provenance: SubmitTurnSubmission['provenance'];
readonly displayContent?: string;
readonly displayAttachments?: readonly UserMessageAttachment[];
readonly requestedTurnId?: string;
readonly hideUserMessage?: boolean;
Expand Down Expand Up @@ -388,8 +389,12 @@ async function completeAfterSettlement(

function directSendDisplayOptions(
input: DirectSendInput,
): Pick<NonNullable<SubmitTurnSubmission['delivery']>, 'displayAttachments' | 'hideUserMessage'> {
): Pick<
NonNullable<SubmitTurnSubmission['delivery']>,
'displayContent' | 'displayAttachments' | 'hideUserMessage'
> {
return {
...(input.displayContent !== undefined ? { displayContent: input.displayContent } : {}),
...(input.displayAttachments
? { displayAttachments: input.displayAttachments.map((attachment) => ({ ...attachment })) }
: {}),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { parsePluginMentions } from '@mavis/shared/plugin-mention';
import { randomUUID } from 'node:crypto';

import {
Expand Down Expand Up @@ -126,6 +127,7 @@ export class UserMessageTurnDeliveryService implements UserMessageTurnDelivery {
...(input.message.userMessageId ? { userMessageId: input.message.userMessageId } : {}),
unstartedFromTurnIds: input.message.unstartedFromTurnIds,
content: projectedMessageContent(delivery, input.message.message.text),
inputContent: input.message.message.text,
...(attachments ? { attachments } : {}),
...(input.message.sourceMessageId ? { sourceMessageId: input.message.sourceMessageId } : {}),
provenance: input.message.provenance,
Expand Down Expand Up @@ -295,6 +297,7 @@ async function commitTurnMessages(
messageKey: member.messageKey,
userMessageId: member.userMessageId,
content: projectedMessageContent(member.message, member.message.content),
inputContent: member.message.content,
timestamp: member.createdAt,
...(member.unconsumedFromTurnIds
? { unconsumedFromTurnIds: member.unconsumedFromTurnIds }
Expand Down Expand Up @@ -323,6 +326,7 @@ async function commitTurnMessage(
messageKey: input.messageKey ?? `turn:${turnId}`,
...(input.userMessageId ? { userMessageId: input.userMessageId } : {}),
content: projectedMessageContent(input, input.input.text),
inputContent: input.input.text,
...(queryKey ? { queryKey } : {}),
...(attachments ? { attachments } : {}),
...(input.sourceMessageId ? { sourceMessageId: input.sourceMessageId } : {}),
Expand All @@ -340,6 +344,7 @@ function commitMessage(
readonly messageKey: string;
readonly userMessageId?: UserMessageId;
readonly content: string;
readonly inputContent: string;
readonly timestamp?: number;
readonly unconsumedFromTurnIds?: readonly string[];
readonly unstartedFromTurnIds?: readonly string[];
Expand All @@ -360,6 +365,9 @@ function commitMessage(
messageKey: input.messageKey,
...(input.userMessageId ? { userMessageId: input.userMessageId } : {}),
content: input.content,
...(input.inputContent !== input.content && parsePluginMentions(input.inputContent).length > 0
? { editContent: input.inputContent }
: {}),
...(input.timestamp !== undefined ? { timestamp: input.timestamp } : {}),
...(input.unconsumedFromTurnIds
? { unconsumedFromTurnIds: input.unconsumedFromTurnIds }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ export function toSessionMessageView(
);
return {
msgId: maybeString(firstDefined([message.msgId, message.msg_id])) ?? "",
...(typeof message.editContent === "string" ? { editContent: message.editContent } : {}),
parentMsgId: maybeString(
firstDefined([message.parentMsgId, message.parent_msg_id]),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type { AppDb } from '../../../../infra/db/client.js';

export interface DisplayMessageRecord extends Record<string, unknown> {
readonly msg_id?: string;
/** Canonical user input preserved when display text hides plugin identities. */
readonly editContent?: string;
/** Stable canonical identity of this completed assistant message, when known. */
readonly canonical_message_id?: string;
readonly role?: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export interface CommitUserMessageInput {
/** Pre-generated identity shared by Queue, Display and canonical history. */
readonly userMessageId?: UserMessageId;
readonly content?: string;
/** Canonical input retained when readable display text hides explicit plugin identities. */
readonly editContent?: string;
/** Display classification independent from Turn/query identity. */
readonly kind?: string;
/** Query sidecar identity; absent only for legacy or non-query user Messages. */
Expand Down Expand Up @@ -118,6 +120,7 @@ export class UserMessageCommitService {
msg_id: messageId,
role: 'user',
msg_content: input.content ?? '',
...(input.editContent !== undefined ? { editContent: input.editContent } : {}),
msg_type: 1,
timestamp: input.timestamp ?? this.nowMs(),
kind: input.kind,
Expand Down
Loading
Loading