@illuma-ai/agents 1.4.0-alpha.4 → 1.4.0-alpha.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/content/ArtifactStore.cjs +579 -0
- package/dist/cjs/content/ArtifactStore.cjs.map +1 -0
- package/dist/cjs/content/ContentStore.cjs +638 -0
- package/dist/cjs/content/ContentStore.cjs.map +1 -0
- package/dist/cjs/content/contentAnalyzer.cjs +91 -0
- package/dist/cjs/content/contentAnalyzer.cjs.map +1 -0
- package/dist/cjs/content/index.cjs +20 -0
- package/dist/cjs/content/index.cjs.map +1 -0
- package/dist/cjs/content/mcpAutoCache.cjs +115 -0
- package/dist/cjs/content/mcpAutoCache.cjs.map +1 -0
- package/dist/cjs/main.cjs +10 -0
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/providers/tools-server/ToolsServerCapabilityProvider.cjs +4 -1
- package/dist/cjs/providers/tools-server/ToolsServerCapabilityProvider.cjs.map +1 -1
- package/dist/cjs/tools/proxyTool.cjs +7 -5
- package/dist/cjs/tools/proxyTool.cjs.map +1 -1
- package/dist/esm/content/ArtifactStore.mjs +576 -0
- package/dist/esm/content/ArtifactStore.mjs.map +1 -0
- package/dist/esm/content/ContentStore.mjs +635 -0
- package/dist/esm/content/ContentStore.mjs.map +1 -0
- package/dist/esm/content/contentAnalyzer.mjs +87 -0
- package/dist/esm/content/contentAnalyzer.mjs.map +1 -0
- package/dist/esm/content/index.mjs +5 -0
- package/dist/esm/content/index.mjs.map +1 -0
- package/dist/esm/content/mcpAutoCache.mjs +111 -0
- package/dist/esm/content/mcpAutoCache.mjs.map +1 -0
- package/dist/esm/main.mjs +3 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/providers/tools-server/ToolsServerCapabilityProvider.mjs +4 -1
- package/dist/esm/providers/tools-server/ToolsServerCapabilityProvider.mjs.map +1 -1
- package/dist/esm/tools/proxyTool.mjs +7 -5
- package/dist/esm/tools/proxyTool.mjs.map +1 -1
- package/dist/types/content/ArtifactStore.d.ts +223 -0
- package/dist/types/content/ContentStore.d.ts +140 -0
- package/dist/types/content/contentAnalyzer.d.ts +38 -0
- package/dist/types/content/index.d.ts +24 -0
- package/dist/types/content/mcpAutoCache.d.ts +89 -0
- package/dist/types/content/types.d.ts +75 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/providers/tools-server/ToolsServerCapabilityProvider.d.ts +14 -0
- package/dist/types/tools/proxyTool.d.ts +7 -0
- package/package.json +6 -1
- package/src/content/ArtifactStore.ts +782 -0
- package/src/content/ContentStore.ts +753 -0
- package/src/content/contentAnalyzer.ts +105 -0
- package/src/content/index.ts +51 -0
- package/src/content/mcpAutoCache.ts +185 -0
- package/src/content/types.ts +82 -0
- package/src/index.ts +19 -0
- package/src/providers/__tests__/ToolsServerCapabilityProvider.test.ts +65 -0
- package/src/providers/tools-server/ToolsServerCapabilityProvider.ts +21 -0
- package/src/tools/proxyTool.ts +25 -5
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utilities for measuring, classifying, and previewing content.
|
|
3
|
+
* Used by the content_tool and MCP auto-caching (Phase 2) to decide
|
|
4
|
+
* when content is "large" and how to summarize it for the LLM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Threshold in characters above which content is considered "large"
|
|
9
|
+
* and should be stored in ContentStore rather than inlined.
|
|
10
|
+
* 50K chars ~ 12.5K tokens ~ 6% of 200K context window.
|
|
11
|
+
*/
|
|
12
|
+
const LARGE_CONTENT_THRESHOLD = 50_000;
|
|
13
|
+
|
|
14
|
+
/** Content size measurements. */
|
|
15
|
+
export interface ContentMeasurement {
|
|
16
|
+
totalChars: number;
|
|
17
|
+
totalLines: number;
|
|
18
|
+
/** True if content exceeds the large-content threshold. */
|
|
19
|
+
isLarge: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Detected content type. */
|
|
23
|
+
export type ContentType = 'json_array' | 'json_object' | 'text' | 'mixed';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Measure content size and determine if it exceeds the large-content threshold.
|
|
27
|
+
* @param text - The content to measure.
|
|
28
|
+
* @returns Measurement with char count, line count, and large flag.
|
|
29
|
+
*/
|
|
30
|
+
export function measureContent(text: string): ContentMeasurement {
|
|
31
|
+
return {
|
|
32
|
+
totalChars: text.length,
|
|
33
|
+
totalLines: text.split('\n').length,
|
|
34
|
+
isLarge: text.length > LARGE_CONTENT_THRESHOLD,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Detect the structural type of content.
|
|
40
|
+
* @param text - The content to classify.
|
|
41
|
+
* @returns The detected type: 'json_array', 'json_object', 'text', or 'mixed'.
|
|
42
|
+
*/
|
|
43
|
+
export function detectContentType(text: string): ContentType {
|
|
44
|
+
const trimmed = text.trim();
|
|
45
|
+
if (!trimmed) {
|
|
46
|
+
return 'text';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Fast check: does it look like JSON?
|
|
50
|
+
if (trimmed[0] === '[' || trimmed[0] === '{') {
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(trimmed);
|
|
53
|
+
if (Array.isArray(parsed)) {
|
|
54
|
+
return 'json_array';
|
|
55
|
+
}
|
|
56
|
+
if (typeof parsed === 'object' && parsed !== null) {
|
|
57
|
+
return 'json_object';
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// Not valid JSON — might be mixed content
|
|
61
|
+
if (trimmed[0] === '[' || trimmed[0] === '{') {
|
|
62
|
+
return 'mixed';
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return 'text';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Generate a preview/summary of content for the LLM context.
|
|
72
|
+
* For JSON arrays, shows the first N items. For text, truncates with an ellipsis.
|
|
73
|
+
*
|
|
74
|
+
* @param text - The full content to preview.
|
|
75
|
+
* @param opts - Options controlling preview size.
|
|
76
|
+
* @returns A truncated preview string.
|
|
77
|
+
*/
|
|
78
|
+
export function generatePreview(
|
|
79
|
+
text: string,
|
|
80
|
+
opts?: { maxItems?: number; maxChars?: number }
|
|
81
|
+
): string {
|
|
82
|
+
const maxItems = opts?.maxItems ?? 5;
|
|
83
|
+
const maxChars = opts?.maxChars ?? 2048;
|
|
84
|
+
const contentType = detectContentType(text);
|
|
85
|
+
|
|
86
|
+
if (contentType === 'json_array') {
|
|
87
|
+
try {
|
|
88
|
+
const arr = JSON.parse(text.trim()) as unknown[];
|
|
89
|
+
if (arr.length <= maxItems) {
|
|
90
|
+
return text.trim();
|
|
91
|
+
}
|
|
92
|
+
const preview = arr.slice(0, maxItems);
|
|
93
|
+
const result = JSON.stringify(preview, null, 2);
|
|
94
|
+
return `${result}\n... (${arr.length - maxItems} more items, ${arr.length} total)`;
|
|
95
|
+
} catch {
|
|
96
|
+
// Fall through to text truncation
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (text.length <= maxChars) {
|
|
101
|
+
return text;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return `${text.substring(0, maxChars)}\n... (truncated, ${text.length} chars total)`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @illuma-ai/agents/content — per-conversation content + artifact stores.
|
|
3
|
+
*
|
|
4
|
+
* Host-agnostic primitives for keeping large tool / agent output out of
|
|
5
|
+
* the LLM context window:
|
|
6
|
+
*
|
|
7
|
+
* - {@link ContentStore} — ephemeral per-conversation cache (backed by
|
|
8
|
+
* any caller-provided {@link Keyv} instance; recommended with
|
|
9
|
+
* @keyv/redis for multi-instance deployments).
|
|
10
|
+
* - {@link ArtifactStore} — extends {@link ContentStore} with
|
|
11
|
+
* durable persistence via caller-provided {@link S3Strategy} and
|
|
12
|
+
* {@link FileModel} adapters.
|
|
13
|
+
* - {@link interceptMcpResult} — MCP tool-result auto-caching with
|
|
14
|
+
* gate semantics (no-op when the agent can't dereference
|
|
15
|
+
* `content_id`s).
|
|
16
|
+
* - {@link measureContent} / {@link detectContentType} /
|
|
17
|
+
* {@link generatePreview} — content classifiers shared by
|
|
18
|
+
* consumers that need to decide when to store vs inline.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export { ContentStore, CONTENT_TTL_MS } from './ContentStore';
|
|
22
|
+
export {
|
|
23
|
+
ArtifactStore,
|
|
24
|
+
sanitizeName,
|
|
25
|
+
type S3Strategy,
|
|
26
|
+
type FileModel,
|
|
27
|
+
type Logger,
|
|
28
|
+
} from './ArtifactStore';
|
|
29
|
+
export {
|
|
30
|
+
measureContent,
|
|
31
|
+
detectContentType,
|
|
32
|
+
generatePreview,
|
|
33
|
+
type ContentMeasurement,
|
|
34
|
+
type ContentType,
|
|
35
|
+
} from './contentAnalyzer';
|
|
36
|
+
export type {
|
|
37
|
+
StoreEntry,
|
|
38
|
+
StoredEntry,
|
|
39
|
+
ContentMetadata,
|
|
40
|
+
ReadResult,
|
|
41
|
+
ReadAllResult,
|
|
42
|
+
SearchMatch,
|
|
43
|
+
EditResult,
|
|
44
|
+
} from './types';
|
|
45
|
+
export {
|
|
46
|
+
interceptMcpResult,
|
|
47
|
+
extractUiMarkers,
|
|
48
|
+
buildCachedResponse,
|
|
49
|
+
type AutoCacheContext,
|
|
50
|
+
type AutoCacheResult,
|
|
51
|
+
} from './mcpAutoCache';
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auto-Caching Interceptor
|
|
3
|
+
*
|
|
4
|
+
* When an MCP tool returns a large text result (>50K chars / ~12.5K tokens),
|
|
5
|
+
* stores it in the caller-provided {@link ContentStore} and returns a
|
|
6
|
+
* compact metadata reference. The LLM then uses a `content_reader` tool
|
|
7
|
+
* (read/search/list/info) to pull relevant pieces of the stored result
|
|
8
|
+
* without burning tokens on the full payload.
|
|
9
|
+
*
|
|
10
|
+
* Gate: callers MUST pass `contentReaderEnabled: true` on the context —
|
|
11
|
+
* otherwise the interceptor returns the original text unchanged, because
|
|
12
|
+
* caching without a reader tool leaves the agent with a content_id it
|
|
13
|
+
* cannot dereference.
|
|
14
|
+
*
|
|
15
|
+
* Design:
|
|
16
|
+
* - Only text content is cached. Images and UI resources pass through.
|
|
17
|
+
* - UI resource markers (\ui{...}) are preserved in the returned text.
|
|
18
|
+
* - Artifacts (second element of the tuple) are never modified.
|
|
19
|
+
* - Cached response is a compact one-liner (~30 tokens) — no preview blob.
|
|
20
|
+
* - If the store write fails, degrades gracefully — returns original text.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { ContentStore } from './ContentStore';
|
|
24
|
+
import { measureContent, detectContentType } from './contentAnalyzer';
|
|
25
|
+
import type { Logger } from './ArtifactStore';
|
|
26
|
+
import type { ContentMeasurement } from './contentAnalyzer';
|
|
27
|
+
|
|
28
|
+
/** Context for the auto-cache interceptor. */
|
|
29
|
+
export interface AutoCacheContext {
|
|
30
|
+
/**
|
|
31
|
+
* Pre-constructed {@link ContentStore} instance scoped to the current
|
|
32
|
+
* conversation. Caller owns the underlying cache lifecycle.
|
|
33
|
+
*/
|
|
34
|
+
store: ContentStore;
|
|
35
|
+
/** MCP server name (e.g. "sharepoint", "github"). */
|
|
36
|
+
serverName: string;
|
|
37
|
+
/** MCP tool name (e.g. "read_file", "search_code"). */
|
|
38
|
+
toolName: string;
|
|
39
|
+
/**
|
|
40
|
+
* Whether the current agent has `content_reader` available. When false,
|
|
41
|
+
* the interceptor passes the large text through unchanged — caching
|
|
42
|
+
* without a reader tool leaves the agent with a content_id it cannot
|
|
43
|
+
* dereference, which is worse than returning the raw text.
|
|
44
|
+
*/
|
|
45
|
+
contentReaderEnabled: boolean;
|
|
46
|
+
/**
|
|
47
|
+
* Optional diagnostic echo. Typically the conversation ID so operators
|
|
48
|
+
* can correlate the log line with upstream traces.
|
|
49
|
+
*/
|
|
50
|
+
conversationId?: string;
|
|
51
|
+
/** Optional logger; defaults to silence. */
|
|
52
|
+
logger?: Logger;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Result of the auto-cache interception. */
|
|
56
|
+
export interface AutoCacheResult {
|
|
57
|
+
/** The (possibly modified) text content to return to the LLM. */
|
|
58
|
+
text: string;
|
|
59
|
+
/** Whether the content was cached. */
|
|
60
|
+
cached: boolean;
|
|
61
|
+
/** The content_id if cached. */
|
|
62
|
+
contentId?: string;
|
|
63
|
+
/** Content measurement data. */
|
|
64
|
+
measurement?: ContentMeasurement;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Regex to detect UI resource markers: \ui{...}
|
|
69
|
+
* These MUST be preserved in the returned text even after caching.
|
|
70
|
+
*/
|
|
71
|
+
const UI_MARKER_REGEX = /\\ui\{[^}]+\}/g;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Extract all UI resource markers from text.
|
|
75
|
+
* @param text - The text to scan.
|
|
76
|
+
* @returns Array of marker strings (e.g. ['\\ui{abc123}', '\\ui{def456}'])
|
|
77
|
+
*/
|
|
78
|
+
export function extractUiMarkers(text: string): string[] {
|
|
79
|
+
return text.match(UI_MARKER_REGEX) ?? [];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build a compact metadata reference for the cached content.
|
|
84
|
+
* Keeps token usage minimal (~30 tokens) while giving the LLM all it needs
|
|
85
|
+
* to access the data via content_tool.
|
|
86
|
+
*
|
|
87
|
+
* @param contentId - ContentStore entry ID.
|
|
88
|
+
* @param measurement - Size data.
|
|
89
|
+
* @param toolName - The MCP tool that produced the result.
|
|
90
|
+
* @param uiMarkers - UI markers extracted from the original text.
|
|
91
|
+
*/
|
|
92
|
+
export function buildCachedResponse(
|
|
93
|
+
contentId: string,
|
|
94
|
+
measurement: ContentMeasurement,
|
|
95
|
+
toolName: string,
|
|
96
|
+
uiMarkers: string[]
|
|
97
|
+
): string {
|
|
98
|
+
const sizeKB = (measurement.totalChars / 1024).toFixed(0);
|
|
99
|
+
let response = `[Stored: ${toolName} result | ${sizeKB}KB | ${measurement.totalLines} lines | content_id: ${contentId}]\nUse content_reader (action: read or search) with this content_id to access the full result — do NOT re-run the MCP tool.`;
|
|
100
|
+
|
|
101
|
+
if (uiMarkers.length > 0) {
|
|
102
|
+
response += '\n\n' + uiMarkers.join('\n');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return response;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Core auto-cache interceptor for MCP tool results.
|
|
110
|
+
*
|
|
111
|
+
* If the text exceeds the large-content threshold (50K chars), stores it
|
|
112
|
+
* in ContentStore and returns a preview + content_id. Otherwise passes through.
|
|
113
|
+
*
|
|
114
|
+
* @param text - The text content from the MCP tool result.
|
|
115
|
+
* @param context - MCP tool and conversation context.
|
|
116
|
+
* @returns AutoCacheResult with possibly-modified text and caching metadata.
|
|
117
|
+
*/
|
|
118
|
+
export async function interceptMcpResult(
|
|
119
|
+
text: string,
|
|
120
|
+
context: AutoCacheContext
|
|
121
|
+
): Promise<AutoCacheResult> {
|
|
122
|
+
const measurement = measureContent(text);
|
|
123
|
+
const log = context.logger;
|
|
124
|
+
|
|
125
|
+
if (!measurement.isLarge) {
|
|
126
|
+
return { text, cached: false, measurement };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Gate: caching only makes sense when the agent can read back the stub.
|
|
130
|
+
// If content_reader is disabled, returning a content_id the agent can't
|
|
131
|
+
// dereference is strictly worse than returning the full text — the model
|
|
132
|
+
// would either hallucinate tool calls or flag the result as inaccessible.
|
|
133
|
+
if (!context.contentReaderEnabled) {
|
|
134
|
+
log?.debug(
|
|
135
|
+
`[MCP Auto-Cache] Skipped caching for ${context.serverName}:${context.toolName} — content_reader disabled on this agent`,
|
|
136
|
+
{
|
|
137
|
+
totalChars: measurement.totalChars,
|
|
138
|
+
totalLines: measurement.totalLines,
|
|
139
|
+
conversationId: context.conversationId,
|
|
140
|
+
}
|
|
141
|
+
);
|
|
142
|
+
return { text, cached: false, measurement };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
const contentType = detectContentType(text);
|
|
147
|
+
|
|
148
|
+
const contentId = await context.store.store({
|
|
149
|
+
name: `${context.toolName} result`,
|
|
150
|
+
type: contentType === 'text' ? 'text/plain' : 'application/json',
|
|
151
|
+
content: text,
|
|
152
|
+
source: `mcp:${context.serverName}`,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const uiMarkers = extractUiMarkers(text);
|
|
156
|
+
|
|
157
|
+
const replacementText = buildCachedResponse(
|
|
158
|
+
contentId,
|
|
159
|
+
measurement,
|
|
160
|
+
context.toolName,
|
|
161
|
+
uiMarkers
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
log?.debug(
|
|
165
|
+
`[MCP Auto-Cache] Cached large result from ${context.serverName}:${context.toolName}`,
|
|
166
|
+
{
|
|
167
|
+
contentId,
|
|
168
|
+
totalChars: measurement.totalChars,
|
|
169
|
+
totalLines: measurement.totalLines,
|
|
170
|
+
conversationId: context.conversationId,
|
|
171
|
+
contentType,
|
|
172
|
+
uiMarkersPreserved: uiMarkers.length,
|
|
173
|
+
}
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return { text: replacementText, cached: true, contentId, measurement };
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// PERF: If caching fails, fall through silently — full content goes to LLM
|
|
179
|
+
log?.warn(
|
|
180
|
+
`[MCP Auto-Cache] Failed to cache result from ${context.serverName}:${context.toolName}, passing through`,
|
|
181
|
+
{ error: (error as Error).message }
|
|
182
|
+
);
|
|
183
|
+
return { text, cached: false, measurement };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Types for the per-conversation content store.
|
|
3
|
+
* Content entries are ephemeral (Redis-backed, 5 min TTL) and used to keep
|
|
4
|
+
* large tool results out of the LLM context window.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Input when storing new content. */
|
|
8
|
+
export interface StoreEntry {
|
|
9
|
+
/** Human-readable name (e.g. "Q1 Sales Report.xlsx") */
|
|
10
|
+
name: string;
|
|
11
|
+
/** MIME-like type: "text/plain", "application/json", "mcp_response", "artifact" */
|
|
12
|
+
type: string;
|
|
13
|
+
/** The raw content string */
|
|
14
|
+
content: string;
|
|
15
|
+
/** Origin identifier: "mcp:sharepoint", "artifact:msg123", "agent", etc. */
|
|
16
|
+
source: string;
|
|
17
|
+
/** Arbitrary extra data attached to the entry */
|
|
18
|
+
metadata?: Record<string, unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Metadata returned by info() and list() — content is NOT included. */
|
|
22
|
+
export interface ContentMetadata {
|
|
23
|
+
id: string;
|
|
24
|
+
name: string;
|
|
25
|
+
type: string;
|
|
26
|
+
source: string;
|
|
27
|
+
totalLines: number;
|
|
28
|
+
totalChars: number;
|
|
29
|
+
createdAt: number;
|
|
30
|
+
/** MongoDB File.file_id after persistence to S3 (set by ArtifactStore) */
|
|
31
|
+
fileId?: string;
|
|
32
|
+
/** Owner user ID (set by ArtifactStore for S3 path construction) */
|
|
33
|
+
userId?: string;
|
|
34
|
+
/** Conversation scope (set by ArtifactStore for S3 path construction) */
|
|
35
|
+
conversationId?: string;
|
|
36
|
+
/** True when the file exists in MongoDB but hasn't been ingested into ContentStore yet */
|
|
37
|
+
needsIngestion?: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Result of a readLines() call. */
|
|
41
|
+
export interface ReadResult {
|
|
42
|
+
/** Formatted content with line numbers */
|
|
43
|
+
content: string;
|
|
44
|
+
startLine: number;
|
|
45
|
+
endLine: number;
|
|
46
|
+
totalLines: number;
|
|
47
|
+
totalChars: number;
|
|
48
|
+
/** True if there are more lines beyond endLine */
|
|
49
|
+
truncated: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Result of a readAll() call — raw content without line-number formatting. */
|
|
53
|
+
export interface ReadAllResult {
|
|
54
|
+
/** Raw content string (no line-number prefixes) */
|
|
55
|
+
content: string;
|
|
56
|
+
totalLines: number;
|
|
57
|
+
totalChars: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A single search match within content. */
|
|
61
|
+
export interface SearchMatch {
|
|
62
|
+
lineNumber: number;
|
|
63
|
+
content: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Result of a strReplace() edit. */
|
|
67
|
+
export interface EditResult {
|
|
68
|
+
success: boolean;
|
|
69
|
+
/** Human-readable diff snippet */
|
|
70
|
+
diff: string;
|
|
71
|
+
/** Line number where the replacement occurred */
|
|
72
|
+
lineNumber: number;
|
|
73
|
+
/** Number of lines affected by the edit */
|
|
74
|
+
linesAffected: number;
|
|
75
|
+
error?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Internal shape stored in Redis for each content entry. */
|
|
79
|
+
export interface StoredEntry {
|
|
80
|
+
content: string;
|
|
81
|
+
metadata: ContentMetadata;
|
|
82
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,25 @@ export * from './tools/proxyTool';
|
|
|
33
33
|
/* Capability Providers */
|
|
34
34
|
export * from './providers';
|
|
35
35
|
|
|
36
|
+
/* Content / artifact stores.
|
|
37
|
+
* Prefer the subpath import `@illuma-ai/agents/content` — this barrel
|
|
38
|
+
* only re-exports the store classes to avoid symbol collisions with
|
|
39
|
+
* existing top-level exports (e.g., Logger from tools/search,
|
|
40
|
+
* ContentType from elsewhere). */
|
|
41
|
+
export { ContentStore, CONTENT_TTL_MS } from './content/ContentStore';
|
|
42
|
+
export { ArtifactStore, sanitizeName } from './content/ArtifactStore';
|
|
43
|
+
export type {
|
|
44
|
+
S3Strategy,
|
|
45
|
+
FileModel,
|
|
46
|
+
Logger as ContentLogger,
|
|
47
|
+
} from './content/ArtifactStore';
|
|
48
|
+
export {
|
|
49
|
+
interceptMcpResult,
|
|
50
|
+
extractUiMarkers,
|
|
51
|
+
buildCachedResponse,
|
|
52
|
+
} from './content/mcpAutoCache';
|
|
53
|
+
export type { AutoCacheContext, AutoCacheResult } from './content/mcpAutoCache';
|
|
54
|
+
|
|
36
55
|
/* Memory (storage + factory) */
|
|
37
56
|
export * from './memory';
|
|
38
57
|
|
|
@@ -203,4 +203,69 @@ describe('ToolsServerCapabilityProvider.createRunnables', () => {
|
|
|
203
203
|
/missing API key/
|
|
204
204
|
);
|
|
205
205
|
});
|
|
206
|
+
|
|
207
|
+
it('getExecuteAuthHeaders is invoked per call and forwarded as HTTP headers', async () => {
|
|
208
|
+
const client = {
|
|
209
|
+
get: jest.fn().mockResolvedValue({ status: 200, data: manifestFixture }),
|
|
210
|
+
post: jest.fn().mockResolvedValue({
|
|
211
|
+
status: 200,
|
|
212
|
+
data: {
|
|
213
|
+
success: true,
|
|
214
|
+
result: 'ok',
|
|
215
|
+
timing: { durationMs: 1 },
|
|
216
|
+
},
|
|
217
|
+
}),
|
|
218
|
+
defaults: { baseURL: 'http://stub', headers: {} },
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
let mintCount = 0;
|
|
222
|
+
const p = new ToolsServerCapabilityProvider({
|
|
223
|
+
baseUrl: 'http://x',
|
|
224
|
+
apiKey: 'k',
|
|
225
|
+
client: client as unknown as ReturnType<typeof axios.create>,
|
|
226
|
+
getExecuteAuthHeaders: async () => {
|
|
227
|
+
mintCount += 1;
|
|
228
|
+
return { Authorization: `Bearer TOKEN-${mintCount}` };
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
const caps = await p.fetchManifest();
|
|
232
|
+
const [wikipedia] = await p.createRunnables(
|
|
233
|
+
caps.filter((c) => c.name === 'wikipedia'),
|
|
234
|
+
{}
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
await wikipedia.invoke({ query: 'a' });
|
|
238
|
+
await wikipedia.invoke({ query: 'b' });
|
|
239
|
+
|
|
240
|
+
// Two invocations → two mints (fresh token each call)
|
|
241
|
+
expect(mintCount).toBe(2);
|
|
242
|
+
|
|
243
|
+
// Each POST includes the Authorization header from the mint
|
|
244
|
+
const firstCall = (client.post as jest.Mock).mock.calls[0];
|
|
245
|
+
const secondCall = (client.post as jest.Mock).mock.calls[1];
|
|
246
|
+
expect(firstCall[2]?.headers).toEqual({ Authorization: 'Bearer TOKEN-1' });
|
|
247
|
+
expect(secondCall[2]?.headers).toEqual({ Authorization: 'Bearer TOKEN-2' });
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('getExecuteAuthHeaders is NOT called during manifest fetch (service-to-service)', async () => {
|
|
251
|
+
const client = {
|
|
252
|
+
get: jest.fn().mockResolvedValue({ status: 200, data: manifestFixture }),
|
|
253
|
+
post: jest.fn(),
|
|
254
|
+
defaults: { baseURL: 'http://stub', headers: {} },
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
const authBuilder = jest
|
|
258
|
+
.fn()
|
|
259
|
+
.mockReturnValue({ Authorization: 'Bearer X' });
|
|
260
|
+
const p = new ToolsServerCapabilityProvider({
|
|
261
|
+
baseUrl: 'http://x',
|
|
262
|
+
apiKey: 'k',
|
|
263
|
+
client: client as unknown as ReturnType<typeof axios.create>,
|
|
264
|
+
getExecuteAuthHeaders: authBuilder,
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
await p.fetchManifest();
|
|
268
|
+
// Manifest fetch is service-to-service — no per-user auth headers
|
|
269
|
+
expect(authBuilder).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
206
271
|
});
|
|
@@ -48,6 +48,21 @@ export interface ToolsServerConfig {
|
|
|
48
48
|
client?: AxiosInstance;
|
|
49
49
|
/** Optional proxy override (defaults to process.env.PROXY). */
|
|
50
50
|
proxy?: string | null;
|
|
51
|
+
/**
|
|
52
|
+
* Optional per-request auth header builder — invoked on every tool
|
|
53
|
+
* invocation (NOT on the manifest fetch, which is service-to-service).
|
|
54
|
+
* When provided, the returned headers are merged into the `/execute`
|
|
55
|
+
* request so the host can pass user-scoped identity (e.g.,
|
|
56
|
+
* `Authorization: Bearer <jwt>`) that tools-server verifies for
|
|
57
|
+
* admin-gated tools.
|
|
58
|
+
*
|
|
59
|
+
* Typical host wiring: mint a short-lived JWT per call carrying the
|
|
60
|
+
* authenticated user's `{ userId, role }` claims; tools-server's
|
|
61
|
+
* `TOOLS_SERVER_JWT_SECRET` validates.
|
|
62
|
+
*/
|
|
63
|
+
getExecuteAuthHeaders?: () =>
|
|
64
|
+
| Record<string, string>
|
|
65
|
+
| Promise<Record<string, string>>;
|
|
51
66
|
}
|
|
52
67
|
|
|
53
68
|
/**
|
|
@@ -91,6 +106,9 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
|
|
|
91
106
|
private readonly manifestPath: string;
|
|
92
107
|
private readonly executePath: string;
|
|
93
108
|
private readonly cache: ManifestCache;
|
|
109
|
+
private readonly getExecuteAuthHeaders?: () =>
|
|
110
|
+
| Record<string, string>
|
|
111
|
+
| Promise<Record<string, string>>;
|
|
94
112
|
|
|
95
113
|
constructor(private readonly config: ToolsServerConfig) {
|
|
96
114
|
const {
|
|
@@ -102,6 +120,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
|
|
|
102
120
|
manifestTtlMs = 60_000,
|
|
103
121
|
client,
|
|
104
122
|
proxy,
|
|
123
|
+
getExecuteAuthHeaders,
|
|
105
124
|
} = config;
|
|
106
125
|
|
|
107
126
|
if (!baseUrl) {
|
|
@@ -115,6 +134,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
|
|
|
115
134
|
this.manifestPath = manifestPath;
|
|
116
135
|
this.executePath = executePath;
|
|
117
136
|
this.cache = new ManifestCache({ ttlMs: manifestTtlMs });
|
|
137
|
+
this.getExecuteAuthHeaders = getExecuteAuthHeaders;
|
|
118
138
|
|
|
119
139
|
if (client) {
|
|
120
140
|
this.client = client;
|
|
@@ -184,6 +204,7 @@ export class ToolsServerCapabilityProvider implements CapabilityProvider {
|
|
|
184
204
|
buildProxyTool(cap, credentials, {
|
|
185
205
|
client: this.client,
|
|
186
206
|
executePath: this.executePath,
|
|
207
|
+
getAuthHeaders: this.getExecuteAuthHeaders,
|
|
187
208
|
})
|
|
188
209
|
);
|
|
189
210
|
}
|
package/src/tools/proxyTool.ts
CHANGED
|
@@ -40,6 +40,15 @@ export interface ProxyToolOptions {
|
|
|
40
40
|
* telemetry, debug logging. Errors in the hook are swallowed.
|
|
41
41
|
*/
|
|
42
42
|
onExecute?: (ctx: ExecuteCallbackContext) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Optional per-invocation auth header builder. Called on every tool
|
|
45
|
+
* invocation before POSTing; returned headers are merged into the
|
|
46
|
+
* request alongside the base client's headers. Typical use: pass a
|
|
47
|
+
* freshly minted per-user JWT for admin-gated tools.
|
|
48
|
+
*/
|
|
49
|
+
getAuthHeaders?: () =>
|
|
50
|
+
| Record<string, string>
|
|
51
|
+
| Promise<Record<string, string>>;
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export interface ExecuteCallbackContext {
|
|
@@ -61,7 +70,12 @@ export function buildProxyTool(
|
|
|
61
70
|
credentials: CredentialMap,
|
|
62
71
|
options: ProxyToolOptions
|
|
63
72
|
): StructuredToolInterface {
|
|
64
|
-
const {
|
|
73
|
+
const {
|
|
74
|
+
client,
|
|
75
|
+
executePath = '/execute/:name',
|
|
76
|
+
onExecute,
|
|
77
|
+
getAuthHeaders,
|
|
78
|
+
} = options;
|
|
65
79
|
const url = executePath.replace(':name', encodeURIComponent(capability.name));
|
|
66
80
|
|
|
67
81
|
return tool(
|
|
@@ -76,10 +90,16 @@ export function buildProxyTool(
|
|
|
76
90
|
`${debugPrefix} invoking — inputKeys=${input && typeof input === 'object' ? Object.keys(input as object).length : 0}`
|
|
77
91
|
);
|
|
78
92
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
const extraHeaders = getAuthHeaders
|
|
94
|
+
? await getAuthHeaders()
|
|
95
|
+
: undefined;
|
|
96
|
+
const res = await client.post<ExecuteResponse>(
|
|
97
|
+
url,
|
|
98
|
+
{ input, credentials },
|
|
99
|
+
extraHeaders && Object.keys(extraHeaders).length > 0
|
|
100
|
+
? { headers: extraHeaders }
|
|
101
|
+
: undefined
|
|
102
|
+
);
|
|
83
103
|
|
|
84
104
|
const durationMs = Date.now() - startMs;
|
|
85
105
|
|