diff --git a/src/__tests__/unit/core/mcp-host-extension.test.ts b/src/__tests__/unit/core/mcp-host-extension.test.ts index c82f5c53..8a27d991 100644 --- a/src/__tests__/unit/core/mcp-host-extension.test.ts +++ b/src/__tests__/unit/core/mcp-host-extension.test.ts @@ -389,6 +389,66 @@ describe('defineMcpHostExtension', () => { .toBe(output.html.artifact.id); }); + it('mirror-mode rules persist the artifact but keep the value inline when auto capture is enabled', async () => { + const context = contextFixture(); + const mdx = `# Deck\n\n---\n\n## Slide\n\n${'content '.repeat(64)}`; + vi.spyOn(McpService.prototype, 'callTool').mockResolvedValue({ + ok: true, + output: { + structuredContent: { + result: { + source: mdx, + validation: { valid: true }, + }, + }, + }, + }); + const extension = defineMcpHostExtension({ + id: 'slides', + serverId: 'deck_service', + includeTools: ['create-deck'], + resultArtifacts: { + auto: { minChars: 16 }, + rules: [{ + toolName: 'create-deck', + path: 'structuredContent.result.source', + mode: 'mirror', + kind: 'source', + domain: 'deck', + title: 'deck.mdx', + setCurrent: true, + }], + }, + }); + const [tool] = extension.toolkits?.flatMap((toolkit) => toolkit.createTools(context)) ?? []; + if (!tool) { + throw new Error('Expected create-deck host tool'); + } + + const result = await tool.execute({ title: 'Quarterly plan' }); + const output = result.output as { + structuredContent: { result: { source: unknown; validation: { valid: boolean } } }; + }; + + expect(result.ok).toBe(true); + // The value the model sees is untouched — including by the subsequent auto + // pass — so the next stateless tool call can pass it straight back in. + expect(output.structuredContent.result.source).toBe(mdx); + expect(output.structuredContent.result.validation).toEqual({ valid: true }); + + // ...while the artifact was persisted and marked current for the host. + const artifacts = new ArtifactService({ artifactRoot: context.artifactRoot }); + const current = artifacts.current('session-123'); + expect(current).toEqual(expect.objectContaining({ + kind: 'source', + domain: 'deck', + title: 'deck.mdx', + sourceTool: 'create_deck', + })); + expect(artifacts.read(current!.id)?.content).toBe(mdx); + expect(artifacts.list({ sessionId: 'session-123' })).toHaveLength(1); + }); + it('auto-stores large duplicate MCP result strings as a single artifact', async () => { const context = contextFixture(); const html = `${'x'.repeat(64)}`; diff --git a/src/core/chat/engine/mcp-host-extension/README.md b/src/core/chat/engine/mcp-host-extension/README.md index e1c24366..a44a93c5 100644 --- a/src/core/chat/engine/mcp-host-extension/README.md +++ b/src/core/chat/engine/mcp-host-extension/README.md @@ -65,6 +65,29 @@ host author should be able to write: resultArtifacts: true ``` +### Mirror mode (keep the value inline) + +Replacement compaction breaks hosts whose MCP server is **stateless** — where +each tool call takes the current document as input and returns the updated +document (so the model must keep the full value inline for the next call). +For that shape, a manual rule can use `mode: 'mirror'`: + +```ts +resultArtifacts: [{ + toolName: 'create-deck', + path: 'structuredContent.result.source', + mode: 'mirror', // persist the artifact, leave the value inline untouched + kind: 'source', + setCurrent: true, // host reads the outcome via engine.artifacts.current(...) +}] +``` + +The model keeps seeing the full value; the host gets a durable artifact and a +one-line way to read "the outcome of this turn". `replacePaths` is ignored in +mirror mode. When manual mirror rules and `auto` capture are combined, the +mirrored path and its descendants are excluded from the auto pass so the full +value remains inline as declared. + Only add public options such as `path`, `pathIncludes`, or explicit `replacePaths` for advanced cases where inference cannot be reliable. When a new inference is added, cover it in diff --git a/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts b/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts index 71d1ab27..83a9bbb9 100644 --- a/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts +++ b/src/core/chat/engine/mcp-host-extension/artifact-path-service.ts @@ -53,4 +53,10 @@ export class McpArtifactPathService { static isArtifactReferencePath(path: string[]): boolean { return path.includes('artifact') || path.includes('contentPath') || path.includes('omittedCharacters'); } + + static isWithin(path: string[], ancestor: readonly string[]): boolean { + return ancestor.length > 0 + && ancestor.length <= path.length + && ancestor.every((part, index) => path[index] === part); + } } diff --git a/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts b/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts index d2396b61..7d82720b 100644 --- a/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts +++ b/src/core/chat/engine/mcp-host-extension/auto-result-artifact-service.ts @@ -26,6 +26,8 @@ export class McpAutoResultArtifactService { const originalCandidates = McpAutoResultArtifactService.findStringResultCandidates(outputBeforeAuto); const candidates = originalCandidates .filter((candidate) => candidate.content.length >= minChars) + .filter((candidate) => !(args.excludedPaths ?? []).some((path) => + McpArtifactPathService.isWithin(candidate.path, path))) .filter((candidate) => !McpArtifactPathService.isArtifactReferencePath(candidate.path)) .filter((candidate) => !McpStructuredContentMirrorService.isSerializedStructuredContentMirror({ candidate, diff --git a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts index b9d1ee15..635767df 100644 --- a/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts +++ b/src/core/chat/engine/mcp-host-extension/result-artifact-service.ts @@ -33,11 +33,16 @@ export class McpResultArtifactService { output: currentOutput, rule, }), cloneDeep(args.output)); + const mirroredPaths = rules + .filter((rule) => rule.mode === 'mirror') + .map((rule) => McpArtifactPathService.normalize(rule.path)) + .filter((path) => path.length > 0); return resultArtifacts.auto ? McpAutoResultArtifactService.apply({ ...args, auto: resultArtifacts.auto, + excludedPaths: mirroredPaths, output: manuallyCaptured, }) : manuallyCaptured; @@ -76,6 +81,12 @@ export class McpResultArtifactService { }, setCurrent: args.rule.setCurrent, }); + // Mirror mode: the artifact is persisted (above), but the value stays + // inline so downstream tool calls can keep consuming it directly. + if (args.rule.mode === 'mirror') { + return args.output; + } + const preview = content.slice(0, args.rule.maxPreviewChars ?? 1_000); const artifactOutput = { artifact: { diff --git a/src/core/chat/engine/mcp-host-extension/types.ts b/src/core/chat/engine/mcp-host-extension/types.ts index b135585d..ad5b6281 100644 --- a/src/core/chat/engine/mcp-host-extension/types.ts +++ b/src/core/chat/engine/mcp-host-extension/types.ts @@ -18,6 +18,19 @@ export type McpHostResultArtifactRule = { toolName: string; /** Path inside the MCP result output that should be persisted as an artifact. */ path: string | readonly string[]; + /** + * How the captured value is returned to the model. + * + * - `'replace'` (default): swap the value for a compact artifact reference — + * the context-compaction behavior. + * - `'mirror'`: persist the artifact but leave the value inline and + * untouched. Use when downstream tool calls need the full value as input + * (e.g. stateless MCP servers that take the current document as an + * argument) while the host still wants a durable artifact — typically with + * `setCurrent: true` so the host reads the outcome via + * `engine.artifacts.current(...)`. `replacePaths` is ignored in this mode. + */ + mode?: 'replace' | 'mirror'; /** Additional output paths to replace with the same artifact reference. */ replacePaths?: ReadonlyArray; kind: ArtifactKind; @@ -167,4 +180,5 @@ export type McpManualResultArtifactApplyInput = McpResultArtifactApplyInput & { export type McpAutoResultArtifactApplyInput = McpResultArtifactApplyInput & { auto: McpHostAutoResultArtifactsOptions; + excludedPaths?: ReadonlyArray; };