-
Notifications
You must be signed in to change notification settings - Fork 0
fix: add tool support to Anthropic LLM for graph memory compatibility #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
5c2d718
76843b4
b0e9779
4e5d09b
56f47f5
69b1995
5e9cc8a
b6f5ca7
362a5ba
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,37 +18,104 @@ export class AnthropicLLM implements LLM { | |
| async generateResponse( | ||
| messages: Message[], | ||
| responseFormat?: { type: string }, | ||
| ): Promise<string> { | ||
| // Extract system message if present | ||
| tools?: any[], | ||
| toolChoice: string = "auto", | ||
| ): Promise<string | LLMResponse> { | ||
| const systemMessage = messages.find((msg) => msg.role === "system"); | ||
| const otherMessages = messages.filter((msg) => msg.role !== "system"); | ||
|
|
||
| const response = await this.client.messages.create({ | ||
| model: this.model, | ||
| messages: otherMessages.map((msg) => ({ | ||
| let mappedMessages = messages | ||
| .filter((msg) => msg.role !== "system") | ||
| .map((msg) => ({ | ||
| role: msg.role as "user" | "assistant", | ||
| content: | ||
| typeof msg.content === "string" | ||
| ? msg.content | ||
| : msg.content.image_url.url, | ||
| })), | ||
| system: | ||
| typeof systemMessage?.content === "string" | ||
| ? systemMessage.content | ||
| : undefined, | ||
| })); | ||
|
|
||
| if (responseFormat?.type === "json_object" && !tools) { | ||
| const last = mappedMessages[mappedMessages.length - 1]; | ||
| if (last && last.role === "user") { | ||
| mappedMessages = [ | ||
| ...mappedMessages.slice(0, -1), | ||
| { ...last, content: last.content + "\n\nYou must respond with valid JSON only." }, | ||
| ]; | ||
| } | ||
| } | ||
|
|
||
| const systemContent = | ||
| typeof systemMessage?.content === "string" ? systemMessage.content : undefined; | ||
|
|
||
| const params: Record<string, any> = { | ||
| model: this.model, | ||
| messages: mappedMessages, | ||
| system: systemContent, | ||
| max_tokens: 4096, | ||
| }); | ||
| }; | ||
|
|
||
| if (tools) { | ||
| params.tools = this._convertTools(tools); | ||
| const mapped = this._mapToolChoice(toolChoice); | ||
| if (mapped !== null) { | ||
| params.tool_choice = mapped; | ||
| } | ||
| } | ||
|
|
||
| const response = await this.client.messages.create(params as any); | ||
|
|
||
| if (tools) { | ||
| let content = ""; | ||
| const toolCalls: Array<{ name: string; arguments: string }> = []; | ||
| for (const block of response.content) { | ||
| if (block.type === "text") { | ||
| content = block.text; | ||
| } else if (block.type === "tool_use") { | ||
| toolCalls.push({ | ||
| name: block.name, | ||
| arguments: JSON.stringify(block.input), | ||
| }); | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note: arguments are JSON-stringified here (matching the OpenAI tool_call format that |
||
| } | ||
| } | ||
| return { content, role: "assistant", toolCalls }; | ||
| } | ||
|
|
||
| const firstBlock = response.content[0]; | ||
| if (!firstBlock) { | ||
| throw new Error("Empty response from Anthropic API"); | ||
| } | ||
| if (firstBlock.type === "text") { | ||
| return firstBlock.text; | ||
| } else { | ||
| throw new Error("Unexpected response type from Anthropic API"); | ||
| } | ||
| throw new Error("Unexpected response type from Anthropic API"); | ||
| } | ||
|
|
||
| private _mapToolChoice(toolChoice: string): Record<string, string> | null { | ||
| if (toolChoice === "auto") return { type: "auto" }; | ||
| if (toolChoice === "required") return { type: "any" }; | ||
| if (toolChoice === "none") return null; | ||
| return { type: "tool", name: toolChoice }; | ||
| } | ||
|
|
||
| private _convertTools(tools: any[]): any[] { | ||
|
Comment on lines
+94
to
+98
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maps OpenAI-style
|
||
| // Validate structure before mapping to catch malformed tool definitions early | ||
| return tools.map((tool, i) => { | ||
| if (!tool.function) { | ||
| throw new Error(`Tool at index ${i} is missing required key 'function'`); | ||
| } | ||
| const { name, description, parameters } = tool.function; | ||
| if (!name || !description || !parameters) { | ||
| throw new Error( | ||
| `Tool at index ${i} is missing required function keys (name, description, parameters)`, | ||
| ); | ||
| } | ||
| return { name, description, input_schema: parameters }; | ||
| }); | ||
| } | ||
|
|
||
| async generateChat(messages: Message[]): Promise<LLMResponse> { | ||
| const response = await this.generateResponse(messages); | ||
| if (typeof response !== "string") { | ||
| throw new Error("generateChat received a non-string response; use generateResponse with tools instead"); | ||
| } | ||
| return { | ||
| content: response, | ||
| role: "assistant", | ||
|
Comment on lines
119
to
121
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Runtime guard replacing an unsafe |
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,15 +23,6 @@ interface SearchOutput { | |
| similarity: number; | ||
| } | ||
|
|
||
| interface ToolCall { | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These local |
||
| name: string; | ||
| arguments: string; | ||
| } | ||
|
|
||
| interface LLMResponse { | ||
| toolCalls?: ToolCall[]; | ||
| } | ||
|
|
||
| interface Tool { | ||
| type: string; | ||
| function: { | ||
|
|
@@ -52,7 +43,7 @@ export class MemoryGraph { | |
| private graph: Driver; | ||
| private embeddingModel: Embedder; | ||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| private llm: LLM; | ||
| private structuredLlm: LLM; | ||
|
|
||
| private llmProvider: string; | ||
| private threshold: number; | ||
|
|
||
|
|
@@ -79,19 +70,10 @@ export class MemoryGraph { | |
| this.config.embedder.config, | ||
| ); | ||
|
Comment on lines
70
to
71
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simplified from a three-step |
||
|
|
||
| this.llmProvider = "openai"; | ||
| if (this.config.llm?.provider) { | ||
| this.llmProvider = this.config.llm.provider; | ||
| } | ||
| if (this.config.graphStore?.llm?.provider) { | ||
| this.llmProvider = this.config.graphStore.llm.provider; | ||
| } | ||
| this.llmProvider = | ||
| this.config.graphStore?.llm?.provider ?? this.config.llm?.provider ?? "openai"; | ||
|
|
||
| this.llm = LLMFactory.create(this.llmProvider, this.config.llm.config); | ||
| this.structuredLlm = LLMFactory.create( | ||
| this.llmProvider, | ||
| this.config.llm.config, | ||
| ); | ||
| this.threshold = 0.7; | ||
| } | ||
|
|
||
|
|
@@ -208,7 +190,7 @@ export class MemoryGraph { | |
| filters: Record<string, any>, | ||
| ) { | ||
| const tools = [EXTRACT_ENTITIES_TOOL] as Tool[]; | ||
| const searchResults = await this.structuredLlm.generateResponse( | ||
| const searchResults = await this.llm.generateResponse( | ||
| [ | ||
| { | ||
| role: "system", | ||
|
|
@@ -284,7 +266,7 @@ export class MemoryGraph { | |
| } | ||
|
|
||
| const tools = [RELATIONS_TOOL] as Tool[]; | ||
| const extractedEntities = await this.structuredLlm.generateResponse( | ||
| const extractedEntities = await this.llm.generateResponse( | ||
| messages, | ||
| { type: "json_object" }, | ||
| tools, | ||
|
|
@@ -294,8 +276,12 @@ export class MemoryGraph { | |
| if (typeof extractedEntities !== "string" && extractedEntities.toolCalls) { | ||
| const toolCall = extractedEntities.toolCalls[0]; | ||
| if (toolCall && toolCall.arguments) { | ||
| const args = JSON.parse(toolCall.arguments); | ||
| entities = args.entities || []; | ||
| try { | ||
| const args = JSON.parse(toolCall.arguments); | ||
| entities = args.entities || []; | ||
| } catch (e) { | ||
| logger.error(`Failed to parse relation tool arguments: ${e}`); | ||
| } | ||
|
Comment on lines
276
to
+284
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added try/catch around |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -385,7 +371,7 @@ export class MemoryGraph { | |
| ); | ||
|
|
||
| const tools = [DELETE_MEMORY_TOOL_GRAPH] as Tool[]; | ||
| const memoryUpdates = await this.structuredLlm.generateResponse( | ||
| const memoryUpdates = await this.llm.generateResponse( | ||
| [ | ||
| { role: "system", content: systemPrompt }, | ||
| { role: "user", content: userPrompt }, | ||
|
|
@@ -398,7 +384,11 @@ export class MemoryGraph { | |
| if (typeof memoryUpdates !== "string" && memoryUpdates.toolCalls) { | ||
| for (const item of memoryUpdates.toolCalls) { | ||
| if (item.name === "delete_graph_memory") { | ||
| toBeDeleted.push(JSON.parse(item.arguments)); | ||
| try { | ||
| toBeDeleted.push(JSON.parse(item.arguments)); | ||
| } catch (e) { | ||
| logger.error(`Failed to parse delete tool arguments: ${e}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -416,7 +406,8 @@ export class MemoryGraph { | |
|
|
||
| try { | ||
|
Comment on lines
406
to
407
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cypher injection prevention. |
||
| for (const item of toBeDeleted) { | ||
| const { source, destination, relationship } = item; | ||
| const { source, destination } = item; | ||
| const relationship = this._sanitizeRelationshipType(item.relationship); | ||
|
|
||
| const cypher = ` | ||
| MATCH (n {name: $source_name, user_id: $user_id}) | ||
|
|
@@ -454,7 +445,8 @@ export class MemoryGraph { | |
|
|
||
| try { | ||
| for (const item of toBeAdded) { | ||
| const { source, destination, relationship } = item; | ||
| const { source, destination } = item; | ||
| const relationship = this._sanitizeRelationshipType(item.relationship); | ||
| const sourceType = entityTypeMap[source] || "unknown"; | ||
| const destinationType = entityTypeMap[destination] || "unknown"; | ||
|
|
||
|
|
@@ -581,6 +573,20 @@ export class MemoryGraph { | |
| })); | ||
| } | ||
|
|
||
| /** | ||
| * Validate that a relationship type contains only characters safe for Cypher | ||
| * interpolation (alphanumerics and underscores). LLM-controlled values must | ||
| * be validated before being embedded in query strings because Neo4j does not | ||
| * support parameterized relationship types. | ||
| */ | ||
| private _sanitizeRelationshipType(relationship: string): string { | ||
| const normalized = relationship.toLowerCase().replace(/\s+/g, "_"); | ||
| if (!/^[a-z0-9_]+$/.test(normalized)) { | ||
| throw new Error(`Unsafe relationship type rejected: "${relationship}"`); | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| private async _searchSourceNode( | ||
| sourceEmbedding: number[], | ||
| userId: string, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New
toolsandtoolChoiceparameters enable graph memory to use Anthropic for entity extraction via function calling. Return type widens fromPromise<string>toPromise<string | LLMResponse>— callers that don't pass tools still get a plain string.