@illuma-ai/agents 1.4.0-alpha.1 → 1.4.0-alpha.3
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/main.cjs +44 -22
- package/dist/cjs/main.cjs.map +1 -1
- package/dist/cjs/tools/artifacts/schema.cjs +63 -0
- package/dist/cjs/tools/artifacts/schema.cjs.map +1 -0
- package/dist/cjs/tools/artifacts/tool.cjs +213 -0
- package/dist/cjs/tools/artifacts/tool.cjs.map +1 -0
- package/dist/cjs/tools/fileSearch/formatter.cjs +93 -0
- package/dist/cjs/tools/fileSearch/formatter.cjs.map +1 -0
- package/dist/cjs/tools/fileSearch/ragClient.cjs +102 -0
- package/dist/cjs/tools/fileSearch/ragClient.cjs.map +1 -0
- package/dist/cjs/tools/fileSearch/schema.cjs +18 -0
- package/dist/cjs/tools/fileSearch/schema.cjs.map +1 -0
- package/dist/cjs/tools/fileSearch/tool.cjs +155 -0
- package/dist/cjs/tools/fileSearch/tool.cjs.map +1 -0
- package/dist/esm/main.mjs +6 -0
- package/dist/esm/main.mjs.map +1 -1
- package/dist/esm/tools/artifacts/schema.mjs +56 -0
- package/dist/esm/tools/artifacts/schema.mjs.map +1 -0
- package/dist/esm/tools/artifacts/tool.mjs +207 -0
- package/dist/esm/tools/artifacts/tool.mjs.map +1 -0
- package/dist/esm/tools/fileSearch/formatter.mjs +90 -0
- package/dist/esm/tools/fileSearch/formatter.mjs.map +1 -0
- package/dist/esm/tools/fileSearch/ragClient.mjs +98 -0
- package/dist/esm/tools/fileSearch/ragClient.mjs.map +1 -0
- package/dist/esm/tools/fileSearch/schema.mjs +15 -0
- package/dist/esm/tools/fileSearch/schema.mjs.map +1 -0
- package/dist/esm/tools/fileSearch/tool.mjs +152 -0
- package/dist/esm/tools/fileSearch/tool.mjs.map +1 -0
- package/dist/types/index.d.ts +2 -0
- package/dist/types/tools/artifacts/index.d.ts +3 -0
- package/dist/types/tools/artifacts/schema.d.ts +63 -0
- package/dist/types/tools/artifacts/tool.d.ts +16 -0
- package/dist/types/tools/artifacts/types.d.ts +127 -0
- package/dist/types/tools/fileSearch/formatter.d.ts +25 -0
- package/dist/types/tools/fileSearch/index.d.ts +5 -0
- package/dist/types/tools/fileSearch/ragClient.d.ts +32 -0
- package/dist/types/tools/fileSearch/schema.d.ts +13 -0
- package/dist/types/tools/fileSearch/tool.d.ts +18 -0
- package/dist/types/tools/fileSearch/types.d.ts +139 -0
- package/package.json +1 -1
- package/src/index.ts +2 -0
- package/src/tools/artifacts/__tests__/tool.test.ts +243 -0
- package/src/tools/artifacts/index.ts +33 -0
- package/src/tools/artifacts/schema.ts +76 -0
- package/src/tools/artifacts/tool.ts +277 -0
- package/src/tools/artifacts/types.ts +149 -0
- package/src/tools/fileSearch/__tests__/tool.test.ts +261 -0
- package/src/tools/fileSearch/formatter.ts +129 -0
- package/src/tools/fileSearch/index.ts +23 -0
- package/src/tools/fileSearch/ragClient.ts +137 -0
- package/src/tools/fileSearch/schema.ts +19 -0
- package/src/tools/fileSearch/tool.ts +207 -0
- package/src/tools/fileSearch/types.ts +149 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default result formatters.
|
|
3
|
+
*
|
|
4
|
+
* - `plainTextFormatter`: CLI / A2A / generic output. No citation anchors.
|
|
5
|
+
* - `citationAnchorFormatter`: ranger-style `\ue202turn0fileN` anchors with
|
|
6
|
+
* a monotonic `sourceOffset` so multi-call turns stay globally unique.
|
|
7
|
+
*
|
|
8
|
+
* Runtimes can supply their own `FileSearchResultFormatter` to override.
|
|
9
|
+
*/
|
|
10
|
+
import type { FileSearchResultFormatter, FileSearchFile, RagChunk } from './types';
|
|
11
|
+
export declare const plainTextFormatter: FileSearchResultFormatter;
|
|
12
|
+
export interface CitationAnchorFormatterOptions {
|
|
13
|
+
/** Tool name used in the `file_search` artifact wrapper. Defaults to `'file_search'`. */
|
|
14
|
+
toolName?: string;
|
|
15
|
+
/**
|
|
16
|
+
* Monotonic counter for source indices within a turn. Pass the SAME
|
|
17
|
+
* function to the formatter across multiple calls in the same turn so
|
|
18
|
+
* anchors stay globally unique.
|
|
19
|
+
*/
|
|
20
|
+
getSourceOffset?: () => number;
|
|
21
|
+
/** Called after formatting to advance the offset. */
|
|
22
|
+
advanceSourceOffset?: (by: number) => void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createCitationAnchorFormatter(opts?: CitationAnchorFormatterOptions): FileSearchResultFormatter;
|
|
25
|
+
export type { FileSearchResultFormatter, FileSearchFile, RagChunk };
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { createFileSearchTool, FileSearchToolName } from './tool';
|
|
2
|
+
export { HttpRagClient, getRagBaseUrl, RAG_API_URL_ENV, type HttpRagClientOptions, } from './ragClient';
|
|
3
|
+
export { plainTextFormatter, createCitationAnchorFormatter, type CitationAnchorFormatterOptions, } from './formatter';
|
|
4
|
+
export { fileSearchInputSchema, type FileSearchInput } from './schema';
|
|
5
|
+
export type { FileSearchFile, RagChunk, RagClient, RagQueryParams, FileSearchResultFormatter, FileSearchToolCallbacks, FileSearchToolConfig, FileSearchToolLogger, } from './types';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default HTTP RAG client. Posts to `${baseUrl}/query` with the shape
|
|
3
|
+
* rag_api expects (`{ file_id, query, k, entity_id? }`). Runtimes that
|
|
4
|
+
* use a different vector backend implement their own `RagClient`.
|
|
5
|
+
*
|
|
6
|
+
* Auth is runtime-provided per call (via `authHeaders` on the params) so
|
|
7
|
+
* short-lived tokens can be minted per request without the client
|
|
8
|
+
* caching stale credentials.
|
|
9
|
+
*/
|
|
10
|
+
import type { RagClient, RagQueryParams, RagChunk, FileSearchToolLogger } from './types';
|
|
11
|
+
export declare const RAG_API_URL_ENV = "RAG_API_URL";
|
|
12
|
+
/** Resolve base URL at call time so env-var changes propagate. */
|
|
13
|
+
export declare function getRagBaseUrl(override?: string): string;
|
|
14
|
+
export interface HttpRagClientOptions {
|
|
15
|
+
/** Base URL of the RAG service (no trailing slash). Falls back to env. */
|
|
16
|
+
baseUrl?: string;
|
|
17
|
+
/** Default headers sent on every request (e.g., a static API key). */
|
|
18
|
+
defaultHeaders?: Record<string, string>;
|
|
19
|
+
/** Default timeout if params don't override. Default 15_000. */
|
|
20
|
+
defaultTimeoutMs?: number;
|
|
21
|
+
logger?: FileSearchToolLogger;
|
|
22
|
+
}
|
|
23
|
+
export declare class HttpRagClient implements RagClient {
|
|
24
|
+
private readonly baseUrlOverride?;
|
|
25
|
+
private readonly defaultHeaders;
|
|
26
|
+
private readonly defaultTimeoutMs;
|
|
27
|
+
private readonly logger?;
|
|
28
|
+
constructor(opts?: HttpRagClientOptions);
|
|
29
|
+
query(params: RagQueryParams): Promise<RagChunk[]>;
|
|
30
|
+
/** Convert rag_api's tuple format into the library's normalized shape. */
|
|
31
|
+
private normalize;
|
|
32
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const fileSearchInputSchema: z.ZodObject<{
|
|
3
|
+
query: z.ZodString;
|
|
4
|
+
target_files: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
query: string;
|
|
7
|
+
target_files?: string[] | undefined;
|
|
8
|
+
}, {
|
|
9
|
+
query: string;
|
|
10
|
+
target_files?: string[] | undefined;
|
|
11
|
+
}>;
|
|
12
|
+
export type FileSearchInput = z.infer<typeof fileSearchInputSchema>;
|
|
13
|
+
export declare const FileSearchToolName = "file_search";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* file_search tool factory — library-native equivalent of the CodeExecutor
|
|
3
|
+
* pattern. Runtimes supply a `RagClient`, the file list for this turn, and
|
|
4
|
+
* an optional formatter (ranger uses citation anchors; CLI/A2A use plain
|
|
5
|
+
* text).
|
|
6
|
+
*
|
|
7
|
+
* The tool itself:
|
|
8
|
+
* 1. Accepts `{ query, target_files? }` from the LLM.
|
|
9
|
+
* 2. Filters files by `target_files` substring match when provided.
|
|
10
|
+
* 3. Queries each file in bounded concurrent batches.
|
|
11
|
+
* 4. Enforces per-file timeouts (failures isolated per file).
|
|
12
|
+
* 5. Flattens chunks, deprioritizes stale-turn files, caps results.
|
|
13
|
+
* 6. Hands formatted output to the runtime's formatter for final shape.
|
|
14
|
+
*/
|
|
15
|
+
import { DynamicStructuredTool } from '@langchain/core/tools';
|
|
16
|
+
import type { FileSearchToolConfig } from './types';
|
|
17
|
+
export declare function createFileSearchTool(config: FileSearchToolConfig): DynamicStructuredTool;
|
|
18
|
+
export { FileSearchToolName } from './schema';
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-search tool types. Mirrors the web_search / code_executor split —
|
|
3
|
+
* the library owns tool logic; the host supplies a `RagClient` + config
|
|
4
|
+
* shaped to its own deployment (auth strategy, scope identity, file set).
|
|
5
|
+
*/
|
|
6
|
+
export interface FileSearchFile {
|
|
7
|
+
/** Stable identifier within the RAG backend. */
|
|
8
|
+
file_id: string;
|
|
9
|
+
/** Human-readable name surfaced in tool results and prompts. */
|
|
10
|
+
filename: string;
|
|
11
|
+
/**
|
|
12
|
+
* Hint that this file arrived on the *current* conversation turn (as
|
|
13
|
+
* opposed to an earlier turn). Hosts that don't distinguish leave this
|
|
14
|
+
* undefined; the formatter deprioritizes older files when set.
|
|
15
|
+
*/
|
|
16
|
+
isCurrentMessage?: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A single chunk returned by the RAG backend for a query.
|
|
20
|
+
* Shape is normalized here so the library stays independent of any
|
|
21
|
+
* specific RAG service's response format — the `RagClient` is responsible
|
|
22
|
+
* for translating the backend response into this shape.
|
|
23
|
+
*/
|
|
24
|
+
export interface RagChunk {
|
|
25
|
+
file_id: string;
|
|
26
|
+
page_content: string;
|
|
27
|
+
distance: number;
|
|
28
|
+
metadata?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
export interface RagQueryParams {
|
|
31
|
+
file_id: string;
|
|
32
|
+
query: string;
|
|
33
|
+
/** Top-K chunks to return per file. Default 10. */
|
|
34
|
+
k?: number;
|
|
35
|
+
/** Optional tenant/entity ID — forwarded to the backend verbatim. */
|
|
36
|
+
entity_id?: string;
|
|
37
|
+
/**
|
|
38
|
+
* Scope identifier. Hosts use this for per-tenant isolation in the RAG
|
|
39
|
+
* backend (ranger → userId, cli → agentId, a2a → task-id).
|
|
40
|
+
*/
|
|
41
|
+
scope?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Per-request auth headers — the host builds these (e.g., ranger sends
|
|
44
|
+
* `Authorization: Bearer <short-lived-JWT-for-userId>`). Allows
|
|
45
|
+
* different runtimes to use different auth strategies without the
|
|
46
|
+
* library knowing.
|
|
47
|
+
*/
|
|
48
|
+
authHeaders?: Record<string, string>;
|
|
49
|
+
/** Optional per-call timeout override. */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pluggable RAG backend. Runtimes provide an implementation that speaks
|
|
54
|
+
* to whatever vector DB / search service they've deployed (rag_api is the
|
|
55
|
+
* default; azure search, pinecone, etc. are valid alternates).
|
|
56
|
+
*/
|
|
57
|
+
export interface RagClient {
|
|
58
|
+
query(params: RagQueryParams): Promise<RagChunk[]>;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Formatter callback that shapes raw chunks into the runtime's preferred
|
|
62
|
+
* presentation. Ranger uses citation anchors (`\ue202turn0fileN`), CLI
|
|
63
|
+
* uses plain text, A2A server can return structured parts.
|
|
64
|
+
*/
|
|
65
|
+
export interface FileSearchResultFormatter {
|
|
66
|
+
format(chunks: Array<RagChunk & {
|
|
67
|
+
filename: string;
|
|
68
|
+
isCurrentMessage: boolean;
|
|
69
|
+
}>, context: {
|
|
70
|
+
/**
|
|
71
|
+
* Monotonic call index within a single turn. Ranger uses this to
|
|
72
|
+
* keep citation source indices globally unique across multiple
|
|
73
|
+
* file_search invocations.
|
|
74
|
+
*/
|
|
75
|
+
callIndex: number;
|
|
76
|
+
/** Same files list the factory was seeded with (for lookups). */
|
|
77
|
+
files: FileSearchFile[];
|
|
78
|
+
}): {
|
|
79
|
+
/** Message returned to the LLM (content). */
|
|
80
|
+
message: string;
|
|
81
|
+
/** Optional artifact payload (sources, metadata) returned alongside. */
|
|
82
|
+
artifact?: unknown;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export interface FileSearchToolCallbacks {
|
|
86
|
+
/**
|
|
87
|
+
* Fires when a RAG query completes successfully for one file. Hosts can
|
|
88
|
+
* use this for telemetry, streaming UI updates, etc.
|
|
89
|
+
*/
|
|
90
|
+
onFileQueried?: (file: FileSearchFile, chunkCount: number) => void;
|
|
91
|
+
/** Fires when a RAG query fails. Hosts can log or surface via UI. */
|
|
92
|
+
onFileError?: (file: FileSearchFile, error: Error) => void;
|
|
93
|
+
}
|
|
94
|
+
export interface FileSearchToolLogger {
|
|
95
|
+
debug: (msg: string, ...args: unknown[]) => void;
|
|
96
|
+
info: (msg: string, ...args: unknown[]) => void;
|
|
97
|
+
warn: (msg: string, ...args: unknown[]) => void;
|
|
98
|
+
error: (msg: string, ...args: unknown[]) => void;
|
|
99
|
+
}
|
|
100
|
+
export interface FileSearchToolConfig {
|
|
101
|
+
/** The RAG backend client. Required. */
|
|
102
|
+
ragClient: RagClient;
|
|
103
|
+
/** Files the agent has access to this turn. Empty = tool self-reports so. */
|
|
104
|
+
files: FileSearchFile[];
|
|
105
|
+
/** Tenant/entity ID forwarded to the backend per-query. */
|
|
106
|
+
entity_id?: string;
|
|
107
|
+
/**
|
|
108
|
+
* Per-turn scope identity. Most runtimes pin this once (userId/agentId);
|
|
109
|
+
* if omitted, no scope is sent to the backend.
|
|
110
|
+
*/
|
|
111
|
+
scope?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Per-call auth header builder. Called on every tool invocation so
|
|
114
|
+
* the host can mint fresh short-lived tokens (ranger's JWT pattern).
|
|
115
|
+
* When omitted, no auth headers are sent.
|
|
116
|
+
*/
|
|
117
|
+
getAuthHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
|
|
118
|
+
/**
|
|
119
|
+
* Result formatter. When omitted, the default plain-text formatter is
|
|
120
|
+
* used (suitable for CLI/A2A runtimes that don't need citation anchors).
|
|
121
|
+
*/
|
|
122
|
+
formatter?: FileSearchResultFormatter;
|
|
123
|
+
/** Per-file query timeout in ms. Default 15_000. */
|
|
124
|
+
queryTimeoutMs?: number;
|
|
125
|
+
/**
|
|
126
|
+
* Max concurrent in-flight RAG queries. Protects HTTP connection pools
|
|
127
|
+
* under heavy file counts. Default 10.
|
|
128
|
+
*/
|
|
129
|
+
concurrencyLimit?: number;
|
|
130
|
+
/** Top-K chunks per file. Default 10. */
|
|
131
|
+
topK?: number;
|
|
132
|
+
/**
|
|
133
|
+
* Cap on total chunks returned to the LLM after sorting. Default is
|
|
134
|
+
* `max(10, filesCount * 3)`.
|
|
135
|
+
*/
|
|
136
|
+
resultCap?: number;
|
|
137
|
+
callbacks?: FileSearchToolCallbacks;
|
|
138
|
+
logger?: FileSearchToolLogger;
|
|
139
|
+
}
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -26,6 +26,8 @@ export * from './tools/schema';
|
|
|
26
26
|
export * from './tools/handlers';
|
|
27
27
|
export * from './tools/search';
|
|
28
28
|
export * from './tools/memory';
|
|
29
|
+
export * from './tools/fileSearch';
|
|
30
|
+
export * from './tools/artifacts';
|
|
29
31
|
export * from './tools/proxyTool';
|
|
30
32
|
|
|
31
33
|
/* Capability Providers */
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the artifact_tool + content_reader library factories.
|
|
3
|
+
*
|
|
4
|
+
* The factories are thin dispatchers — their job is:
|
|
5
|
+
* 1. Resolve scope from runnableConfig (error if missing)
|
|
6
|
+
* 2. Optionally self-heal content_id via the resolver
|
|
7
|
+
* 3. Validate per-action required args
|
|
8
|
+
* 4. Route to the runtime's handler, propagate errors
|
|
9
|
+
*
|
|
10
|
+
* We verify each of those paths here with mock handlers.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
createArtifactTool,
|
|
15
|
+
createContentReaderTool,
|
|
16
|
+
} from '../tool';
|
|
17
|
+
import type {
|
|
18
|
+
ArtifactWriteHandlers,
|
|
19
|
+
ContentReadHandlers,
|
|
20
|
+
ArtifactToolScope,
|
|
21
|
+
ArtifactToolResult,
|
|
22
|
+
ContentIdResolver,
|
|
23
|
+
} from '../types';
|
|
24
|
+
|
|
25
|
+
function makeScope(): ArtifactToolScope {
|
|
26
|
+
return { conversationId: 'conv-1', userId: 'user-1' };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeWriteHandlers(): ArtifactWriteHandlers & {
|
|
30
|
+
_calls: Array<{ action: string; args: unknown; scope: ArtifactToolScope }>;
|
|
31
|
+
} {
|
|
32
|
+
const calls: Array<{
|
|
33
|
+
action: string;
|
|
34
|
+
args: unknown;
|
|
35
|
+
scope: ArtifactToolScope;
|
|
36
|
+
}> = [];
|
|
37
|
+
return {
|
|
38
|
+
_calls: calls,
|
|
39
|
+
write: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
40
|
+
calls.push({ action: 'write', args, scope });
|
|
41
|
+
return ['wrote it', { id: 'new-id' }];
|
|
42
|
+
}),
|
|
43
|
+
edit: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
44
|
+
calls.push({ action: 'edit', args, scope });
|
|
45
|
+
return ['edited', {}];
|
|
46
|
+
}),
|
|
47
|
+
verify: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
48
|
+
calls.push({ action: 'verify', args, scope });
|
|
49
|
+
return ['verified', {}];
|
|
50
|
+
}),
|
|
51
|
+
delete: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
52
|
+
calls.push({ action: 'delete', args, scope });
|
|
53
|
+
return ['deleted', {}];
|
|
54
|
+
}),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function makeReadHandlers(): ContentReadHandlers & {
|
|
59
|
+
_calls: Array<{ action: string; args: unknown; scope: ArtifactToolScope }>;
|
|
60
|
+
} {
|
|
61
|
+
const calls: Array<{
|
|
62
|
+
action: string;
|
|
63
|
+
args: unknown;
|
|
64
|
+
scope: ArtifactToolScope;
|
|
65
|
+
}> = [];
|
|
66
|
+
return {
|
|
67
|
+
_calls: calls,
|
|
68
|
+
read: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
69
|
+
calls.push({ action: 'read', args, scope });
|
|
70
|
+
return ['read-output', {}];
|
|
71
|
+
}),
|
|
72
|
+
search: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
73
|
+
calls.push({ action: 'search', args, scope });
|
|
74
|
+
return ['search-output', {}];
|
|
75
|
+
}),
|
|
76
|
+
list: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
77
|
+
calls.push({ action: 'list', args, scope });
|
|
78
|
+
return ['list-output', {}];
|
|
79
|
+
}),
|
|
80
|
+
info: jest.fn(async (args, scope): Promise<ArtifactToolResult> => {
|
|
81
|
+
calls.push({ action: 'info', args, scope });
|
|
82
|
+
return ['info-output', {}];
|
|
83
|
+
}),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('createArtifactTool', () => {
|
|
88
|
+
it('returns a scope error when getScope returns null', async () => {
|
|
89
|
+
const handlers = makeWriteHandlers();
|
|
90
|
+
const t = createArtifactTool({
|
|
91
|
+
handlers,
|
|
92
|
+
getScope: () => null,
|
|
93
|
+
});
|
|
94
|
+
const result = await t.invoke({ action: 'write', content: 'x' });
|
|
95
|
+
const text = Array.isArray(result) ? String(result[0]) : String(result);
|
|
96
|
+
expect(text).toMatch(/no conversation context/i);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('dispatches write to handlers.write with resolved args + scope', async () => {
|
|
100
|
+
const handlers = makeWriteHandlers();
|
|
101
|
+
const t = createArtifactTool({
|
|
102
|
+
handlers,
|
|
103
|
+
getScope: makeScope,
|
|
104
|
+
});
|
|
105
|
+
await t.invoke({ action: 'write', content: 'hello', name: 'doc.md' });
|
|
106
|
+
expect(handlers.write).toHaveBeenCalledTimes(1);
|
|
107
|
+
expect(handlers._calls[0].args).toEqual(
|
|
108
|
+
expect.objectContaining({ action: 'write', content: 'hello', name: 'doc.md' }),
|
|
109
|
+
);
|
|
110
|
+
expect(handlers._calls[0].scope).toEqual(
|
|
111
|
+
expect.objectContaining({ conversationId: 'conv-1' }),
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('rejects write without content', async () => {
|
|
116
|
+
const handlers = makeWriteHandlers();
|
|
117
|
+
const t = createArtifactTool({
|
|
118
|
+
handlers,
|
|
119
|
+
getScope: makeScope,
|
|
120
|
+
});
|
|
121
|
+
const result = await t.invoke({ action: 'write' });
|
|
122
|
+
const text = Array.isArray(result) ? String(result[0]) : String(result);
|
|
123
|
+
expect(text).toMatch(/write requires content/i);
|
|
124
|
+
expect(handlers.write).not.toHaveBeenCalled();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('rejects edit/verify/delete without content_id', async () => {
|
|
128
|
+
const handlers = makeWriteHandlers();
|
|
129
|
+
const t = createArtifactTool({
|
|
130
|
+
handlers,
|
|
131
|
+
getScope: makeScope,
|
|
132
|
+
});
|
|
133
|
+
const e = await t.invoke({ action: 'edit', old_str: 'a', new_str: 'b' });
|
|
134
|
+
expect(String(Array.isArray(e) ? e[0] : e)).toMatch(/edit requires content_id/i);
|
|
135
|
+
const v = await t.invoke({ action: 'verify' });
|
|
136
|
+
expect(String(Array.isArray(v) ? v[0] : v)).toMatch(/verify requires content_id/i);
|
|
137
|
+
const d = await t.invoke({ action: 'delete' });
|
|
138
|
+
expect(String(Array.isArray(d) ? d[0] : d)).toMatch(/delete requires content_id/i);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('applies the resolver before dispatching to handler', async () => {
|
|
142
|
+
const handlers = makeWriteHandlers();
|
|
143
|
+
const resolver: ContentIdResolver = {
|
|
144
|
+
resolve: jest.fn(async (id) => ({ resolvedId: `canonical:${id}`, resolvedName: 'X' })),
|
|
145
|
+
};
|
|
146
|
+
const t = createArtifactTool({
|
|
147
|
+
handlers,
|
|
148
|
+
getScope: makeScope,
|
|
149
|
+
resolver,
|
|
150
|
+
});
|
|
151
|
+
await t.invoke({
|
|
152
|
+
action: 'edit',
|
|
153
|
+
content_id: 'nickname',
|
|
154
|
+
old_str: 'a',
|
|
155
|
+
new_str: 'b',
|
|
156
|
+
});
|
|
157
|
+
expect(handlers._calls[0].args).toEqual(
|
|
158
|
+
expect.objectContaining({ content_id: 'canonical:nickname' }),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('catches handler exceptions and returns them as tool errors', async () => {
|
|
163
|
+
const handlers = makeWriteHandlers();
|
|
164
|
+
handlers.write = jest.fn(async () => {
|
|
165
|
+
throw new Error('s3 unavailable');
|
|
166
|
+
});
|
|
167
|
+
const t = createArtifactTool({
|
|
168
|
+
handlers,
|
|
169
|
+
getScope: makeScope,
|
|
170
|
+
});
|
|
171
|
+
const result = await t.invoke({ action: 'write', content: 'x' });
|
|
172
|
+
const text = Array.isArray(result) ? String(result[0]) : String(result);
|
|
173
|
+
expect(text).toMatch(/s3 unavailable/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('honors descriptionOverride for host-specific guidance', async () => {
|
|
177
|
+
const handlers = makeWriteHandlers();
|
|
178
|
+
const t = createArtifactTool({
|
|
179
|
+
handlers,
|
|
180
|
+
getScope: makeScope,
|
|
181
|
+
descriptionOverride: 'HOST-BRAND-GUIDE',
|
|
182
|
+
});
|
|
183
|
+
expect(t.description).toBe('HOST-BRAND-GUIDE');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('createContentReaderTool', () => {
|
|
188
|
+
it('dispatches list with no content_id required', async () => {
|
|
189
|
+
const handlers = makeReadHandlers();
|
|
190
|
+
const t = createContentReaderTool({
|
|
191
|
+
handlers,
|
|
192
|
+
getScope: makeScope,
|
|
193
|
+
});
|
|
194
|
+
await t.invoke({ action: 'list' });
|
|
195
|
+
expect(handlers.list).toHaveBeenCalledTimes(1);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('rejects read/info/search without content_id', async () => {
|
|
199
|
+
const handlers = makeReadHandlers();
|
|
200
|
+
const t = createContentReaderTool({
|
|
201
|
+
handlers,
|
|
202
|
+
getScope: makeScope,
|
|
203
|
+
});
|
|
204
|
+
const r = await t.invoke({ action: 'read' });
|
|
205
|
+
expect(String(Array.isArray(r) ? r[0] : r)).toMatch(/read requires content_id/i);
|
|
206
|
+
const i = await t.invoke({ action: 'info' });
|
|
207
|
+
expect(String(Array.isArray(i) ? i[0] : i)).toMatch(/info requires content_id/i);
|
|
208
|
+
const s = await t.invoke({ action: 'search', pattern: 'p' });
|
|
209
|
+
expect(String(Array.isArray(s) ? s[0] : s)).toMatch(/search requires content_id/i);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('rejects search without pattern even when content_id is given', async () => {
|
|
213
|
+
const handlers = makeReadHandlers();
|
|
214
|
+
const t = createContentReaderTool({
|
|
215
|
+
handlers,
|
|
216
|
+
getScope: makeScope,
|
|
217
|
+
});
|
|
218
|
+
const result = await t.invoke({ action: 'search', content_id: 'x' });
|
|
219
|
+
const text = Array.isArray(result) ? String(result[0]) : String(result);
|
|
220
|
+
expect(text).toMatch(/search requires pattern/i);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('passes read pagination args through to the handler', async () => {
|
|
224
|
+
const handlers = makeReadHandlers();
|
|
225
|
+
const t = createContentReaderTool({
|
|
226
|
+
handlers,
|
|
227
|
+
getScope: makeScope,
|
|
228
|
+
});
|
|
229
|
+
await t.invoke({
|
|
230
|
+
action: 'read',
|
|
231
|
+
content_id: 'c-1',
|
|
232
|
+
start_line: 10,
|
|
233
|
+
end_line: 50,
|
|
234
|
+
});
|
|
235
|
+
expect(handlers._calls[0].args).toEqual(
|
|
236
|
+
expect.objectContaining({
|
|
237
|
+
content_id: 'c-1',
|
|
238
|
+
start_line: 10,
|
|
239
|
+
end_line: 50,
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createArtifactTool,
|
|
3
|
+
createContentReaderTool,
|
|
4
|
+
ARTIFACT_TOOL_NAME,
|
|
5
|
+
CONTENT_READER_NAME,
|
|
6
|
+
ARTIFACT_WRITE_ACTIONS,
|
|
7
|
+
CONTENT_READ_ACTIONS,
|
|
8
|
+
} from './tool';
|
|
9
|
+
export {
|
|
10
|
+
artifactToolSchema,
|
|
11
|
+
contentReaderSchema,
|
|
12
|
+
type ArtifactToolInput,
|
|
13
|
+
type ContentReaderInput,
|
|
14
|
+
} from './schema';
|
|
15
|
+
export type {
|
|
16
|
+
ArtifactToolScope,
|
|
17
|
+
ArtifactToolResult,
|
|
18
|
+
ArtifactToolLogger,
|
|
19
|
+
ArtifactToolBaseConfig,
|
|
20
|
+
ArtifactToolConfig,
|
|
21
|
+
ContentReaderToolConfig,
|
|
22
|
+
ArtifactWriteHandlers,
|
|
23
|
+
ContentReadHandlers,
|
|
24
|
+
ContentIdResolver,
|
|
25
|
+
WriteArgs,
|
|
26
|
+
EditArgs,
|
|
27
|
+
VerifyArgs,
|
|
28
|
+
DeleteArgs,
|
|
29
|
+
ReadArgs,
|
|
30
|
+
SearchArgs,
|
|
31
|
+
ListArgs,
|
|
32
|
+
InfoArgs,
|
|
33
|
+
} from './types';
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const ARTIFACT_WRITE_ACTIONS = ['write', 'edit', 'verify', 'delete'] as const;
|
|
4
|
+
export const CONTENT_READ_ACTIONS = ['read', 'search', 'list', 'info'] as const;
|
|
5
|
+
|
|
6
|
+
export const artifactToolSchema = z.object({
|
|
7
|
+
action: z
|
|
8
|
+
.enum(ARTIFACT_WRITE_ACTIONS)
|
|
9
|
+
.describe(
|
|
10
|
+
'Authoring action: write (create/overwrite), edit (str_replace), verify (syntax check), delete (remove).',
|
|
11
|
+
),
|
|
12
|
+
content_id: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
'ID of the artifact entry. Required for edit/verify/delete; optional for write (supply to overwrite an existing entry).',
|
|
17
|
+
),
|
|
18
|
+
|
|
19
|
+
// write
|
|
20
|
+
content: z
|
|
21
|
+
.string()
|
|
22
|
+
.optional()
|
|
23
|
+
.describe('Full file content (required for write action).'),
|
|
24
|
+
name: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe(
|
|
28
|
+
'Filename for the new entry (required when creating). MUST include the correct file extension — the extension drives the preview template.',
|
|
29
|
+
),
|
|
30
|
+
|
|
31
|
+
// edit (str_replace)
|
|
32
|
+
old_str: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Exact string to find and replace (required for edit).'),
|
|
36
|
+
new_str: z.string().optional().describe('Replacement string (required for edit).'),
|
|
37
|
+
replace_all: z
|
|
38
|
+
.boolean()
|
|
39
|
+
.optional()
|
|
40
|
+
.describe(
|
|
41
|
+
'edit: when true, replaces every occurrence of old_str. When false (default) and old_str matches more than one location, the edit is refused.',
|
|
42
|
+
),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
export const contentReaderSchema = z.object({
|
|
46
|
+
action: z
|
|
47
|
+
.enum(CONTENT_READ_ACTIONS)
|
|
48
|
+
.describe(
|
|
49
|
+
'Read-only action: read (lines), search (regex), list (all entries), info (metadata).',
|
|
50
|
+
),
|
|
51
|
+
content_id: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe(
|
|
55
|
+
'ID of the content entry. Required for read/search/info. Omit for list.',
|
|
56
|
+
),
|
|
57
|
+
|
|
58
|
+
// read pagination
|
|
59
|
+
start_line: z.number().optional().describe('1-based start line for reading.'),
|
|
60
|
+
end_line: z.number().optional().describe('1-based end line (inclusive).'),
|
|
61
|
+
|
|
62
|
+
// search
|
|
63
|
+
pattern: z.string().optional().describe('Regex pattern (required for search).'),
|
|
64
|
+
flags: z.string().optional().describe('Regex flags (e.g., "i" for case-insensitive).'),
|
|
65
|
+
context: z.number().optional().describe('Lines of context around each match.'),
|
|
66
|
+
|
|
67
|
+
// shared pagination
|
|
68
|
+
offset: z.number().optional().describe('Offset for read or search pagination.'),
|
|
69
|
+
limit: z.number().optional().describe('Max lines (read) or matches (search).'),
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
export const ARTIFACT_TOOL_NAME = 'artifact_tool';
|
|
73
|
+
export const CONTENT_READER_NAME = 'content_reader';
|
|
74
|
+
|
|
75
|
+
export type ArtifactToolInput = z.infer<typeof artifactToolSchema>;
|
|
76
|
+
export type ContentReaderInput = z.infer<typeof contentReaderSchema>;
|