feat(ai): Tool call loop, built-in data tools, agent runtime & chat API#1016
feat(ai): Tool call loop, built-in data tools, agent runtime & chat API#1016
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…ent chat route - Add toolCalls to AIResult and chatWithTools to IAIService contract - Implement chatWithTools() on AIService with multi-round tool calling loop - Define 5 built-in data tools: list_objects, describe_object, query_records, get_record, aggregate_data - Create AgentRuntime for agent metadata loading, system prompt injection, model/tools mapping - Add agent chat API route: POST /api/v1/ai/agents/:agentName/chat - Define data_chat agent spec with role, instructions, guardrails - Update AIServicePlugin to auto-register data tools and agent routes via ai:ready hook - Export all new modules from service-ai index Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/10c08b4e-c31c-42cd-a9d4-27917289db5b Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
- Add comprehensive test suite for chatWithTools loop, data tools, agent runtime, agent routes - Export Agent type from @objectstack/spec index for use in service-ai - Update CHANGELOG.md with data chatbot feature entry Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/10c08b4e-c31c-42cd-a9d4-27917289db5b Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
…ny types, enhance JSDoc Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/10c08b4e-c31c-42cd-a9d4-27917289db5b Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR adds end-to-end “tool-augmented chat” infrastructure to support an Airtable Copilot-style data chatbot: contract updates in @objectstack/spec, a tool-call execution loop in AIService, built-in data tools, an agent runtime, and a new agent chat API route (with tests and changelog entry).
Changes:
- Extends the AI contracts to support tool calling (
AIResult.toolCalls,IAIService.chatWithTools,ChatWithToolsOptions) and re-exports theAgenttype. - Implements a multi-iteration tool-call loop (
AIService.chatWithTools) plus registers 5 built-in data tools (registerDataTools) and a built-indata_chatagent. - Adds
AgentRuntimeandPOST /api/v1/ai/agents/:agentName/chatroute wiring, with comprehensive test coverage and changelog update.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/spec/src/index.ts | Re-exports Agent type for downstream consumers. |
| packages/spec/src/contracts/ai-service.ts | Adds tool-call fields + chatWithTools contract/options. |
| packages/services/service-ai/src/tools/index.ts | Exposes data-tool registration + types from the tools module. |
| packages/services/service-ai/src/tools/data-tools.ts | Introduces 5 built-in data tools and registers them into ToolRegistry. |
| packages/services/service-ai/src/routes/agent-routes.ts | Adds an agent chat HTTP route that loads agents, builds system messages, and calls chatWithTools. |
| packages/services/service-ai/src/plugin.ts | Auto-registers data tools + data_chat agent and mounts agent routes when services are available. |
| packages/services/service-ai/src/index.ts | Re-exports new runtime/tools/agent/route APIs for library consumers. |
| packages/services/service-ai/src/ai-service.ts | Implements the tool-call loop (chatWithTools) on the service. |
| packages/services/service-ai/src/agents/index.ts | Adds agent barrel export. |
| packages/services/service-ai/src/agents/data-chat-agent.ts | Defines the built-in DATA_CHAT_AGENT spec. |
| packages/services/service-ai/src/agent-runtime.ts | Adds runtime to load agents, build system prompts, and resolve tool definitions. |
| packages/services/service-ai/src/tests/chatbot-features.test.ts | Adds tests for loop behavior, data tools, agent runtime, and agent routes. |
| CHANGELOG.md | Documents the feature set in Unreleased notes. |
| const maxIterations = options?.maxIterations ?? AIService.DEFAULT_MAX_ITERATIONS; | ||
| const registeredTools = this.toolRegistry.getAll(); | ||
|
|
||
| // Merge registered tools with any explicitly provided tools | ||
| const mergedTools = [ | ||
| ...registeredTools, | ||
| ...(options?.tools ?? []), | ||
| ]; | ||
|
|
||
| // Build the options that will be sent to every LLM call in the loop | ||
| const chatOptions: AIRequestOptions = { | ||
| ...options, | ||
| tools: mergedTools.length > 0 ? mergedTools : undefined, | ||
| toolChoice: mergedTools.length > 0 ? (options?.toolChoice ?? 'auto') : undefined, | ||
| }; |
There was a problem hiding this comment.
chatWithTools() builds chatOptions via { ...options, ... }, which includes maxIterations and will pass it down to this.adapter.chat(...). Adapters that forward options to a provider SDK (OpenAI/Anthropic/etc.) may reject unknown keys or serialize them unexpectedly. Strip maxIterations before building AIRequestOptions (e.g., destructure { maxIterations, ...requestOptions }).
| const safeLimit = Math.min(limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT); | ||
|
|
||
| const records = await ctx.dataEngine.find(objectName, { | ||
| where, | ||
| fields, | ||
| orderBy, | ||
| limit: safeLimit, | ||
| offset, | ||
| }); |
There was a problem hiding this comment.
safeLimit is computed with Math.min(limit ?? DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT), but this will produce a negative limit (or NaN) if the caller supplies a negative/non-finite limit. That can propagate to dataEngine.find(...) and lead to undefined behavior or driver errors. Validate limit/offset as finite integers and clamp to a minimum of 1 (and 0 for offset) before calling the data engine.
| /** Valid message roles accepted by the agent routes. */ | ||
| const VALID_ROLES = new Set<string>(['system', 'user', 'assistant', 'tool']); | ||
|
|
||
| function validateMessage(raw: unknown): string | null { | ||
| if (typeof raw !== 'object' || raw === null) { | ||
| return 'each message must be an object'; | ||
| } | ||
| const msg = raw as Record<string, unknown>; | ||
| if (typeof msg.role !== 'string' || !VALID_ROLES.has(msg.role)) { | ||
| return `message.role must be one of ${[...VALID_ROLES].map(r => `"${r}"`).join(', ')}`; | ||
| } | ||
| if (typeof msg.content !== 'string') { | ||
| return 'message.content must be a string'; | ||
| } | ||
| return null; |
There was a problem hiding this comment.
This agent chat endpoint accepts caller-supplied system and tool role messages, and then prepends the agent's own system message (fullMessages = [...systemMessages, ...rawMessages]). Because caller system messages come after the agent system prompt, a client can override the agent instructions/guardrails. Consider restricting inbound roles for this route to user (and optionally assistant for transcript replay) and rejecting/stripping system + tool messages from the request body.
| const { | ||
| messages: rawMessages, | ||
| context: chatContext, | ||
| options: extraOptions, | ||
| } = (req.body ?? {}) as { | ||
| messages?: unknown[]; | ||
| context?: AgentChatContext; | ||
| options?: Record<string, unknown>; | ||
| }; | ||
|
|
||
| if (!Array.isArray(rawMessages) || rawMessages.length === 0) { | ||
| return { status: 400, body: { error: 'messages array is required' } }; | ||
| } | ||
|
|
||
| for (const msg of rawMessages) { | ||
| const err = validateMessage(msg); | ||
| if (err) return { status: 400, body: { error: err } }; | ||
| } | ||
|
|
||
| // Load agent definition | ||
| const agent = await agentRuntime.loadAgent(agentName); | ||
| if (!agent) { | ||
| return { status: 404, body: { error: `Agent "${agentName}" not found` } }; | ||
| } | ||
| if (!agent.active) { | ||
| return { status: 403, body: { error: `Agent "${agentName}" is not active` } }; | ||
| } | ||
|
|
||
| try { | ||
| // Build system messages from agent instructions + UI context | ||
| const systemMessages = agentRuntime.buildSystemMessages(agent, chatContext); | ||
|
|
||
| // Resolve agent model/tools → request options | ||
| const agentOptions = agentRuntime.buildRequestOptions( | ||
| agent, | ||
| aiService.toolRegistry.getAll(), | ||
| ); | ||
|
|
||
| // Merge agent options with any caller overrides | ||
| const mergedOptions = { ...agentOptions, ...extraOptions }; | ||
|
|
||
| // Prepend system messages then user conversation | ||
| const fullMessages: AIMessage[] = [ | ||
| ...systemMessages, | ||
| ...(rawMessages as AIMessage[]), | ||
| ]; | ||
|
|
||
| // Use chatWithTools for automatic tool resolution | ||
| const result = await aiService.chatWithTools(fullMessages, { | ||
| ...mergedOptions, | ||
| maxIterations: agent.planning?.maxIterations, | ||
| }); |
There was a problem hiding this comment.
extraOptions is taken as an unvalidated Record<string, unknown> and shallow-merged into agentOptions, then passed into chatWithTools. This allows clients to override sensitive request settings (e.g. tools, toolChoice, model, very large maxTokens) and can cause tool-definition injection / DoS loops (LLM calling tools that aren't actually registered). Prefer whitelisting only safe AIRequestOptions keys for caller overrides and explicitly ignoring tools/toolChoice (and/or enforcing server-side caps).
| async loadAgent(agentName: string): Promise<Agent | undefined> { | ||
| const raw = await this.metadataService.get('agent', agentName); | ||
| return raw as Agent | undefined; | ||
| } |
There was a problem hiding this comment.
AgentRuntime.loadAgent() currently returns raw as Agent without validating it, even though the class docstring says it loads & validates agent metadata. If metadata is malformed (missing instructions, wrong types), buildSystemMessages() and buildRequestOptions() can throw at runtime. Consider validating with defineAgent(...) / AgentSchema.parse(...) (or at least checking required fields) and returning undefined / throwing a clear error when validation fails.
| // Register the built-in data_chat agent | ||
| await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT); | ||
| ctx.logger.info('[AI] data_chat agent registered'); |
There was a problem hiding this comment.
AIServicePlugin.start() unconditionally registers DATA_CHAT_AGENT on every startup via metadataService.register('agent', ...). The in-memory metadata manager implementation overwrites existing entries, so this can clobber a user-customized data_chat agent definition. Consider checking exists('agent', DATA_CHAT_AGENT.name) first, or making auto-registration configurable / non-destructive (e.g. register only if absent).
| // Register the built-in data_chat agent | |
| await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT); | |
| ctx.logger.info('[AI] data_chat agent registered'); | |
| // Register the built-in data_chat agent only if it does not already exist | |
| const agentExists = | |
| typeof metadataService.exists === 'function' | |
| ? await metadataService.exists('agent', DATA_CHAT_AGENT.name) | |
| : false; | |
| if (!agentExists) { | |
| await metadataService.register('agent', DATA_CHAT_AGENT.name, DATA_CHAT_AGENT); | |
| ctx.logger.info('[AI] data_chat agent registered'); | |
| } else { | |
| ctx.logger.debug('[AI] data_chat agent already exists, skipping auto-registration'); | |
| } |
| const result = await ctx.dataEngine.aggregate(objectName, { | ||
| where, | ||
| groupBy, | ||
| aggregations: aggregations.map(a => ({ | ||
| function: a.function as AggFn, | ||
| field: a.field, | ||
| alias: a.alias, | ||
| })), | ||
| }); |
There was a problem hiding this comment.
aggregate_data maps a.function with a type cast (a.function as AggFn) but does not validate it at runtime. If the LLM (or a caller) provides an unexpected function string, this will be passed to dataEngine.aggregate(...) and may trigger driver errors. Prefer validating a.function against the allowed set (and returning a structured tool error) instead of casting.
|
@copilot fix all |
1. Strip maxIterations from chatOptions before passing to adapter (prevents unknown-key errors) 2. Validate limit/offset in query_records (clamp negatives, reject NaN, floor to integers) 3. Restrict agent route to user/assistant roles only (reject system/tool to prevent guardrail overrides) 4. Whitelist safe caller overrides in agent route (only temperature/maxTokens/stop; block tools/toolChoice/model) 5. Validate agent with AgentSchema.safeParse() in loadAgent() (returns undefined for malformed metadata) 6. Only register data_chat agent if not already present (preserves user customizations) 7. Validate aggregation function at runtime against allowed set (returns structured error for invalid functions) 9 new tests covering all fixes. 130 total tests pass. Agent-Logs-Url: https://github.com/objectstack-ai/spec/sessions/ad6459f1-27d7-4eda-b0d3-00887c47d5de Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
All 7 review comments addressed in e68ef7d:
9 new tests added for these fixes. 130 total tests pass, build clean, 0 CodeQL alerts. |
Implements the core infrastructure for an Airtable Copilot-style data conversation chatbot — users ask natural language questions against business data, the AI resolves them via tool calls to the data engine.
Contract changes (
@objectstack/spec)AIResult.toolCalls?: AIToolCall[]— adapters can now return tool call requests from the LLMIAIService.chatWithTools?()+ChatWithToolsOptions— optional contract method for tool-augmented chat withmaxIterationssafetyAgenttype from@objectstack/specindexTool call loop (
AIService.chatWithTools)Multi-round LLM ↔ tool execution loop:
ToolRegistrydefinitions into LLM optionstoolCallsvia registry, appends results asrole: 'tool'messagesmaxIterations(default 10) exhaustedmaxIterationsfrom options before forwarding to the adapter to prevent unknown-key errors in provider SDKsBuilt-in data tools
Five platform-level tools registered via
registerDataTools(registry, { dataEngine, metadataService }):list_objectsdescribe_objectquery_recordsget_recordaggregate_dataData tool hardening:
query_recordsvalidateslimit/offsetas finite positive integers, clamping negatives/NaN to defaultsaggregate_datavalidates aggregation function names at runtime against the allowed set (count,sum,avg,min,max,count_distinct), returning a structured error for invalid valuesAgent runtime & chat route
AgentRuntime— loads agent metadata fromIMetadataServicewithAgentSchema.safeParse()validation (returnsundefinedfor malformed metadata), builds system prompt frominstructions+ UI context (objectName,recordId,viewName), resolves agent tool references against the registryPOST /api/v1/ai/agents/:agentName/chat— agent lookup, active-check, context injection, delegates tochatWithToolsuserandassistantonly —systemandtoolmessages are rejected to prevent client-side guardrail overridestemperature,maxTokens,stop); silently ignores sensitive keys (tools,toolChoice,model) to prevent tool-definition injectionDATA_CHAT_AGENT— built-indata_chatagent spec with guardrails, planning config, and all 5 data tool referencesPlugin wiring
AIServicePlugin.start()auto-registers data tools and thedata_chatagent whenIDataEngine+IMetadataServiceare present in the kernel, before firingai:ready. Agent auto-registration is non-destructive — checksexists()first to preserve user customizations.Tests
51 new test cases covering the tool call loop (sequential, parallel, max iterations, error propagation, maxIterations stripping), all 5 data tool handlers (including limit/offset validation and aggregation function validation), agent runtime (loading, validation of malformed metadata, prompt building, tool resolution), agent route (validation, 404/403, role restriction, options whitelisting, happy path), and agent spec validation. All 130 service-ai tests and 6747 spec tests pass.