Skip to content

Commit 552bcfe

Browse files
authored
Tool context injection (#10)
* feat: inject server-only tool context to keep auth out of LLM inputs * chore: bump version to 0.3.0-alpha.1
1 parent 4480d43 commit 552bcfe

7 files changed

Lines changed: 134 additions & 22 deletions

File tree

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,6 +1100,36 @@ enforce access control in your app.
11001100

11011101
---
11021102

1103+
## Injecting trusted context into tools
1104+
1105+
Some tool handlers need server-only context (orgId, externalId, userId) that
1106+
should never appear in the tool schema or LLM prompt. Pass that data via
1107+
`toolContext` in `chat.send` or `chat.sendForExternalId`. The context is merged
1108+
into tool args after validation, and `toolContext` wins on conflicts.
1109+
1110+
```typescript
1111+
await ctx.runAction(components.databaseChat.chat.sendForExternalId, {
1112+
conversationId,
1113+
externalId,
1114+
message,
1115+
config: {
1116+
apiKey: process.env.OPENROUTER_API_KEY!,
1117+
systemPrompt: SYSTEM_PROMPT,
1118+
tools,
1119+
toolContext: {
1120+
orgId,
1121+
userId,
1122+
externalId,
1123+
},
1124+
},
1125+
});
1126+
```
1127+
1128+
Note: Do not include fields like `orgId` in the tool schema so the LLM never
1129+
sees them.
1130+
1131+
---
1132+
11031133
## API Reference
11041134

11051135
### Component Functions

convex/component/chat.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { describe, it, expect } from "vitest";
33
import { convexTest } from "convex-test";
44
import schema from "./schema";
55
import { api } from "./_generated/api";
6+
import { executeToolWithContext } from "./toolExecution";
7+
import type { DatabaseChatTool } from "./tools";
68

79
const modules = import.meta.glob("./**/*.ts");
810

@@ -134,6 +136,47 @@ describe("databaseChat chat", () => {
134136
});
135137
});
136138

139+
describe("tool context injection", () => {
140+
it("merges toolContext into tool args before execution", async () => {
141+
const ctx = {
142+
runQuery: async (_handler: string, args: Record<string, unknown>) => args,
143+
runMutation: async (
144+
_handler: string,
145+
args: Record<string, unknown>
146+
) => args,
147+
runAction: async (_handler: string, args: Record<string, unknown>) =>
148+
args,
149+
};
150+
151+
const tool: DatabaseChatTool = {
152+
name: "searchRecords",
153+
description: "Search records",
154+
parameters: {
155+
type: "object",
156+
properties: {
157+
query: { type: "string" },
158+
},
159+
required: ["query"],
160+
},
161+
handler: "testHandler",
162+
};
163+
164+
const { result, args } = await executeToolWithContext(
165+
ctx,
166+
tool,
167+
{ query: "react", orgId: "llm-org" },
168+
{ orgId: "org123", externalId: "user:1" }
169+
);
170+
171+
expect(result).toEqual({
172+
query: "react",
173+
orgId: "org123",
174+
externalId: "user:1",
175+
});
176+
expect(args).toEqual(result);
177+
});
178+
});
179+
137180
// TODO: Add integration test with mocked fetch for chat.send
138181
// This would require setting up MSW or similar to mock OpenRouter responses
139182
describe.skip("chat.send integration", () => {

convex/component/chat.ts

Lines changed: 10 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./tools";
1111
import type { DatabaseChatTool } from "./tools";
1212
import { DeltaStreamer } from "./deltaStreamer";
13+
import { executeToolWithContext } from "./toolExecution";
1314

1415
const sendConfigValidator = v.object({
1516
apiKey: v.string(),
@@ -19,6 +20,7 @@ const sendConfigValidator = v.object({
1920
tools: v.optional(v.array(databaseChatToolValidator)),
2021
// Max messages to include in LLM context (default: 50)
2122
maxMessagesForLLM: v.optional(v.number()),
23+
toolContext: v.optional(v.any()),
2224
});
2325

2426
const sendReturnValidator = v.object({
@@ -98,6 +100,7 @@ async function sendInternal(
98100
handler: string;
99101
}>;
100102
maxMessagesForLLM?: number;
103+
toolContext?: Record<string, unknown>;
101104
};
102105
}
103106
) {
@@ -207,14 +210,19 @@ async function sendInternal(
207210

208211
// Execute the tool
209212
try {
210-
const result = await executeToolHandler(ctx, tool, parsedArgs);
213+
const { result, args: mergedArgs } = await executeToolWithContext(
214+
ctx,
215+
tool,
216+
parsedArgs,
217+
config.toolContext
218+
);
211219
toolResults.push({
212220
toolCallId: toolCall.id,
213221
result: JSON.stringify(result),
214222
});
215223
executedToolCalls.push({
216224
name: toolCall.name,
217-
args: parsedArgs,
225+
args: mergedArgs,
218226
result,
219227
});
220228
} catch (error) {
@@ -294,23 +302,6 @@ async function sendInternal(
294302
}
295303
}
296304

297-
async function executeToolHandler(
298-
ctx: any,
299-
tool: DatabaseChatTool,
300-
args: Record<string, unknown>
301-
) {
302-
const handlerType = tool.handlerType ?? "query";
303-
switch (handlerType) {
304-
case "mutation":
305-
return await ctx.runMutation(tool.handler as any, args);
306-
case "action":
307-
return await ctx.runAction(tool.handler as any, args);
308-
case "query":
309-
default:
310-
return await ctx.runQuery(tool.handler as any, args);
311-
}
312-
}
313-
314305
const DEFAULT_SYSTEM_PROMPT = `You are a helpful assistant that can search and query a database.
315306
When users ask questions, use the available tools to find relevant information.
316307
If you don't have access to a tool that can answer the question, say so.

convex/component/client.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,8 @@ export interface SendMessageOptions {
133133
model?: string;
134134
/** Override system prompt for this message */
135135
systemPrompt?: string;
136+
/** Server-side context merged into tool args (not exposed to LLM) */
137+
toolContext?: Record<string, unknown>;
136138
}
137139

138140
export interface SendMessageResult {
@@ -349,6 +351,7 @@ export class DatabaseChatClient {
349351
systemPrompt: options.systemPrompt ?? this.config.systemPrompt,
350352
tools: this.tools.length > 0 ? this.tools : undefined,
351353
maxMessagesForLLM: this.config.maxMessagesForLLM ?? 50,
354+
toolContext: options.toolContext,
352355
},
353356
});
354357
}

convex/component/toolExecution.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import type { DatabaseChatTool } from "./tools";
2+
3+
export type ToolExecutionContext = {
4+
runQuery: (handler: any, args: Record<string, unknown>) => Promise<unknown>;
5+
runMutation: (handler: any, args: Record<string, unknown>) => Promise<unknown>;
6+
runAction: (handler: any, args: Record<string, unknown>) => Promise<unknown>;
7+
};
8+
9+
export function mergeToolArgs(
10+
parsedArgs: Record<string, unknown>,
11+
toolContext?: Record<string, unknown>
12+
): Record<string, unknown> {
13+
if (!toolContext || Object.keys(toolContext).length === 0) {
14+
return { ...parsedArgs };
15+
}
16+
return { ...parsedArgs, ...toolContext };
17+
}
18+
19+
export async function executeToolWithContext(
20+
ctx: ToolExecutionContext,
21+
tool: DatabaseChatTool,
22+
parsedArgs: Record<string, unknown>,
23+
toolContext?: Record<string, unknown>
24+
): Promise<{ result: unknown; args: Record<string, unknown> }> {
25+
const mergedArgs = mergeToolArgs(parsedArgs, toolContext);
26+
const result = await executeToolHandler(ctx, tool, mergedArgs);
27+
return { result, args: mergedArgs };
28+
}
29+
30+
async function executeToolHandler(
31+
ctx: ToolExecutionContext,
32+
tool: DatabaseChatTool,
33+
args: Record<string, unknown>
34+
) {
35+
const handlerType = tool.handlerType ?? "query";
36+
switch (handlerType) {
37+
case "mutation":
38+
return await ctx.runMutation(tool.handler as any, args);
39+
case "action":
40+
return await ctx.runAction(tool.handler as any, args);
41+
case "query":
42+
default:
43+
return await ctx.runQuery(tool.handler as any, args);
44+
}
45+
}

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@dayhaysoos/convex-database-chat",
3-
"version": "0.3.0-alpha.0",
3+
"version": "0.3.0-alpha.1",
44
"description": "A Convex component for adding natural language database queries to your app.",
55
"type": "module",
66
"main": "./dist/src/index.js",

0 commit comments

Comments
 (0)