@digimuza/system-core 0.2.0 → 0.3.0-rc.1

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.
Files changed (151) hide show
  1. package/INTEGRATION.md +209 -3
  2. package/README.md +23 -0
  3. package/dist/ai/api.d.ts +120 -0
  4. package/dist/ai/api.d.ts.map +1 -0
  5. package/dist/ai/api.js +446 -0
  6. package/dist/ai/api.js.map +1 -0
  7. package/dist/ai/card.d.ts +27 -0
  8. package/dist/ai/card.d.ts.map +1 -0
  9. package/dist/ai/card.js +84 -0
  10. package/dist/ai/card.js.map +1 -0
  11. package/dist/ai/contract.d.ts +364 -0
  12. package/dist/ai/contract.d.ts.map +1 -0
  13. package/dist/ai/contract.js +101 -0
  14. package/dist/ai/contract.js.map +1 -0
  15. package/dist/ai/display.d.ts +20 -0
  16. package/dist/ai/display.d.ts.map +1 -0
  17. package/dist/ai/display.js +35 -0
  18. package/dist/ai/display.js.map +1 -0
  19. package/dist/ai/index.d.ts +7 -0
  20. package/dist/ai/index.d.ts.map +1 -0
  21. package/dist/ai/index.js +10 -0
  22. package/dist/ai/index.js.map +1 -0
  23. package/dist/ai/manifest.d.ts +25 -0
  24. package/dist/ai/manifest.d.ts.map +1 -0
  25. package/dist/ai/manifest.js +15 -0
  26. package/dist/ai/manifest.js.map +1 -0
  27. package/dist/ai/runner.d.ts +71 -0
  28. package/dist/ai/runner.d.ts.map +1 -0
  29. package/dist/ai/runner.js +246 -0
  30. package/dist/ai/runner.js.map +1 -0
  31. package/dist/ai/tools.d.ts +42 -0
  32. package/dist/ai/tools.d.ts.map +1 -0
  33. package/dist/ai/tools.js +117 -0
  34. package/dist/ai/tools.js.map +1 -0
  35. package/dist/audit/csv.d.ts +31 -0
  36. package/dist/audit/csv.d.ts.map +1 -0
  37. package/dist/audit/csv.js +39 -0
  38. package/dist/audit/csv.js.map +1 -0
  39. package/dist/audit/index.d.ts +5 -0
  40. package/dist/audit/index.d.ts.map +1 -0
  41. package/dist/audit/index.js +6 -0
  42. package/dist/audit/index.js.map +1 -0
  43. package/dist/convex/functions.d.ts +39 -0
  44. package/dist/convex/functions.d.ts.map +1 -1
  45. package/dist/convex/functions.js +38 -6
  46. package/dist/convex/functions.js.map +1 -1
  47. package/dist/convex/index.d.ts +1 -1
  48. package/dist/convex/index.d.ts.map +1 -1
  49. package/dist/convex/index.js.map +1 -1
  50. package/dist/core/access.d.ts +14 -0
  51. package/dist/core/access.d.ts.map +1 -1
  52. package/dist/core/access.js +62 -0
  53. package/dist/core/access.js.map +1 -1
  54. package/dist/core/audit-module.d.ts +5 -0
  55. package/dist/core/audit-module.d.ts.map +1 -0
  56. package/dist/core/audit-module.js +25 -0
  57. package/dist/core/audit-module.js.map +1 -0
  58. package/dist/core/audit.d.ts +31 -0
  59. package/dist/core/audit.d.ts.map +1 -1
  60. package/dist/core/audit.js +46 -2
  61. package/dist/core/audit.js.map +1 -1
  62. package/dist/core/catalog.d.ts +41 -0
  63. package/dist/core/catalog.d.ts.map +1 -1
  64. package/dist/core/catalog.js +45 -0
  65. package/dist/core/catalog.js.map +1 -1
  66. package/dist/core/context.d.ts +8 -2
  67. package/dist/core/context.d.ts.map +1 -1
  68. package/dist/core/context.js +4 -1
  69. package/dist/core/context.js.map +1 -1
  70. package/dist/core/index.d.ts +2 -0
  71. package/dist/core/index.d.ts.map +1 -1
  72. package/dist/core/index.js +2 -0
  73. package/dist/core/index.js.map +1 -1
  74. package/dist/core/notify.d.ts +37 -0
  75. package/dist/core/notify.d.ts.map +1 -0
  76. package/dist/core/notify.js +16 -0
  77. package/dist/core/notify.js.map +1 -0
  78. package/dist/core/roles.d.ts.map +1 -1
  79. package/dist/core/roles.js +5 -1
  80. package/dist/core/roles.js.map +1 -1
  81. package/dist/core/system-modules.d.ts +4 -0
  82. package/dist/core/system-modules.d.ts.map +1 -1
  83. package/dist/core/system-modules.js +11 -0
  84. package/dist/core/system-modules.js.map +1 -1
  85. package/dist/finance/ai-tools.d.ts +55 -0
  86. package/dist/finance/ai-tools.d.ts.map +1 -0
  87. package/dist/finance/ai-tools.js +65 -0
  88. package/dist/finance/ai-tools.js.map +1 -0
  89. package/dist/finance/index.d.ts +87 -1
  90. package/dist/finance/index.d.ts.map +1 -1
  91. package/dist/finance/index.js +92 -38
  92. package/dist/finance/index.js.map +1 -1
  93. package/dist/finance/search.d.ts +15 -0
  94. package/dist/finance/search.d.ts.map +1 -0
  95. package/dist/finance/search.js +51 -0
  96. package/dist/finance/search.js.map +1 -0
  97. package/dist/index.d.ts +2 -0
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/index.js +2 -0
  100. package/dist/index.js.map +1 -1
  101. package/dist/modules/index.d.ts +1 -1
  102. package/dist/modules/index.d.ts.map +1 -1
  103. package/dist/modules/index.js +1 -1
  104. package/dist/modules/index.js.map +1 -1
  105. package/dist/notifications/api.d.ts +130 -0
  106. package/dist/notifications/api.d.ts.map +1 -0
  107. package/dist/notifications/api.js +384 -0
  108. package/dist/notifications/api.js.map +1 -0
  109. package/dist/notifications/contract.d.ts +132 -0
  110. package/dist/notifications/contract.d.ts.map +1 -0
  111. package/dist/notifications/contract.js +59 -0
  112. package/dist/notifications/contract.js.map +1 -0
  113. package/dist/notifications/index.d.ts +6 -0
  114. package/dist/notifications/index.d.ts.map +1 -0
  115. package/dist/notifications/index.js +9 -0
  116. package/dist/notifications/index.js.map +1 -0
  117. package/dist/search/index.d.ts +95 -0
  118. package/dist/search/index.d.ts.map +1 -0
  119. package/dist/search/index.js +151 -0
  120. package/dist/search/index.js.map +1 -0
  121. package/package.json +41 -2
  122. package/src/ai/api.ts +535 -0
  123. package/src/ai/card.ts +91 -0
  124. package/src/ai/contract.ts +245 -0
  125. package/src/ai/display.ts +38 -0
  126. package/src/ai/index.ts +9 -0
  127. package/src/ai/manifest.ts +16 -0
  128. package/src/ai/runner.ts +275 -0
  129. package/src/ai/tools.ts +128 -0
  130. package/src/audit/csv.ts +60 -0
  131. package/src/audit/index.ts +7 -0
  132. package/src/convex/functions.ts +54 -7
  133. package/src/convex/index.ts +1 -1
  134. package/src/core/access.ts +55 -1
  135. package/src/core/audit-module.ts +26 -0
  136. package/src/core/audit.ts +68 -2
  137. package/src/core/catalog.ts +63 -0
  138. package/src/core/context.ts +13 -2
  139. package/src/core/index.ts +2 -0
  140. package/src/core/notify.ts +52 -0
  141. package/src/core/roles.ts +5 -1
  142. package/src/core/system-modules.ts +12 -0
  143. package/src/finance/ai-tools.ts +68 -0
  144. package/src/finance/index.ts +119 -42
  145. package/src/finance/search.ts +58 -0
  146. package/src/index.ts +2 -0
  147. package/src/modules/index.ts +3 -0
  148. package/src/notifications/api.ts +477 -0
  149. package/src/notifications/contract.ts +142 -0
  150. package/src/notifications/index.ts +8 -0
  151. package/src/search/index.ts +198 -0
@@ -0,0 +1,245 @@
1
+ // Shared contract of the AI module (DIG-71). Every part of `@digimuza/system-core/ai`
2
+ // builds on these shapes: tables, views, function names, the tool and agent API.
3
+ // No Mastra or AI SDK imports here: this file is safe in Convex's default runtime
4
+ // and in the browser (types only).
5
+ import { defineTable } from "convex/server"
6
+ import { v, type GenericId, type Infer } from "convex/values"
7
+ import type { z } from "zod"
8
+ import type { Actor, ActorRef } from "../core/access.js"
9
+ import type { SystemContext } from "../core/context.js"
10
+
11
+ // --- Validators & tables -------------------------------------------------------------
12
+
13
+ export const actorRefValidator = v.union(v.object({ kind: v.literal("user"), userId: v.string() }), v.object({ kind: v.literal("agent"), apiKeyId: v.string() }))
14
+
15
+ export const toolCallState = v.union(
16
+ v.literal("awaiting-approval"), // requiresApproval tool, waiting for the user
17
+ v.literal("approved"), // user approved; the runner executes it next
18
+ v.literal("rejected"), // user rejected; the model is told so
19
+ v.literal("running"),
20
+ v.literal("done"),
21
+ v.literal("error"),
22
+ )
23
+
24
+ export const messagePart = v.union(
25
+ v.object({ type: v.literal("text"), text: v.string() }),
26
+ v.object({
27
+ type: v.literal("tool-call"),
28
+ toolCallId: v.string(),
29
+ toolName: v.string(),
30
+ args: v.any(),
31
+ state: toolCallState,
32
+ result: v.optional(v.any()),
33
+ /** Stable system-core error code (FORBIDDEN, VALIDATION, …) or a short message. */
34
+ error: v.optional(v.string()),
35
+ }),
36
+ )
37
+
38
+ export const threadStatus = v.union(v.literal("idle"), v.literal("running"), v.literal("waiting-approval"))
39
+ export const messageStatus = v.union(v.literal("streaming"), v.literal("done"), v.literal("error"), v.literal("stopped"))
40
+ export const runStatus = v.union(v.literal("queued"), v.literal("running"), v.literal("waiting-approval"), v.literal("done"), v.literal("error"), v.literal("stopped"))
41
+
42
+ /** Spread into the app schema next to `systemTables`. */
43
+ export const aiTables = {
44
+ aiThreads: defineTable({
45
+ /** system-core user key of the owner. Only the owner can see a thread. */
46
+ ownerId: v.string(),
47
+ title: v.string(),
48
+ agentId: v.string(),
49
+ status: threadStatus,
50
+ archived: v.boolean(),
51
+ createdAt: v.number(),
52
+ updatedAt: v.number(),
53
+ }).index("by_owner", ["ownerId", "archived", "updatedAt"]),
54
+
55
+ aiMessages: defineTable({
56
+ threadId: v.id("aiThreads"),
57
+ role: v.union(v.literal("user"), v.literal("assistant")),
58
+ parts: v.array(messagePart),
59
+ status: messageStatus,
60
+ /** `operationId` is the run's, so support can find the logs of a failed answer. */
61
+ error: v.optional(v.object({ code: v.string(), message: v.string(), operationId: v.optional(v.string()) })),
62
+ runId: v.optional(v.id("aiRuns")),
63
+ createdAt: v.number(),
64
+ }).index("by_thread", ["threadId", "createdAt"]),
65
+
66
+ aiRuns: defineTable({
67
+ threadId: v.id("aiThreads"),
68
+ /** The assistant message this run writes into. */
69
+ messageId: v.id("aiMessages"),
70
+ /** Who the run acts for; re-authorized on every tool call (system-core authorizeJob). */
71
+ actor: actorRefValidator,
72
+ agentId: v.string(),
73
+ model: v.string(),
74
+ status: runStatus,
75
+ stopRequested: v.boolean(),
76
+ operationId: v.string(),
77
+ error: v.optional(v.object({ code: v.string(), message: v.string() })),
78
+ createdAt: v.number(),
79
+ updatedAt: v.number(),
80
+ })
81
+ .index("by_thread", ["threadId", "createdAt"])
82
+ .index("by_message", ["messageId"]),
83
+ }
84
+
85
+ export type MessagePart = Infer<typeof messagePart>
86
+ export type ToolCallState = Infer<typeof toolCallState>
87
+ export type ThreadStatus = Infer<typeof threadStatus>
88
+ export type MessageStatus = Infer<typeof messageStatus>
89
+ export type RunStatus = Infer<typeof runStatus>
90
+ export type AiThreadId = GenericId<"aiThreads">
91
+ export type AiMessageId = GenericId<"aiMessages">
92
+ export type AiRunId = GenericId<"aiRuns">
93
+
94
+ // --- Views returned by public functions ----------------------------------------------
95
+
96
+ export interface ThreadView {
97
+ id: AiThreadId
98
+ title: string
99
+ agentId: string
100
+ status: ThreadStatus
101
+ createdAt: number
102
+ updatedAt: number
103
+ }
104
+
105
+ export interface MessageView {
106
+ id: AiMessageId
107
+ role: "user" | "assistant"
108
+ parts: MessagePart[]
109
+ status: MessageStatus
110
+ /** Set when the answer failed; `operationId` is the run's (show it so support can trace it). */
111
+ error: { code: string; message: string; operationId?: string } | null
112
+ createdAt: number
113
+ }
114
+
115
+ export interface AgentToolView {
116
+ id: string
117
+ description: string
118
+ requiresApproval: boolean
119
+ }
120
+
121
+ export interface AgentView {
122
+ id: string
123
+ name: string
124
+ description: string
125
+ /** Only the tools the caller holds the permission for right now. */
126
+ tools: AgentToolView[]
127
+ }
128
+
129
+ // --- Tools ----------------------------------------------------------------------------
130
+
131
+ /** What a tool handler gets. It runs inside an internal mutation, as the run's actor. */
132
+ export interface AiToolContext {
133
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
134
+ db: any
135
+ sys: SystemContext
136
+ /** Re-authorized for `permission` right before the handler runs. */
137
+ actor: Actor
138
+ }
139
+
140
+ export interface AiTool<I extends z.ZodType = z.ZodType, O = unknown> {
141
+ /** Stable, `module.verb_object` style, e.g. `finance.list_invoices`. Shown to the model. */
142
+ id: string
143
+ /** For the model: what it does and when to use it. */
144
+ description: string
145
+ /** Zod schema of the arguments (also sent to the model as JSON schema). */
146
+ input: I
147
+ /** system-core permission required to run it, e.g. `finance:invoice:read`. */
148
+ permission: string
149
+ /** Writes or anything risky: the user approves each call first. */
150
+ requiresApproval?: boolean
151
+ /**
152
+ * Must be JSON-serializable. Throw SystemError for expected failures.
153
+ *
154
+ * Validate everything before the first write: a failed handler does not roll
155
+ * back. `executeAiTool` returns the failure instead of throwing (so the
156
+ * `ai.tool.run` audit survives), therefore writes made before the throw are
157
+ * committed with the surrounding mutation.
158
+ */
159
+ handler(ctx: AiToolContext, input: z.infer<I>): Promise<O>
160
+ }
161
+
162
+ // --- Agents ---------------------------------------------------------------------------
163
+
164
+ export interface AiAgentDefinition {
165
+ /** Stable id, e.g. `assistant`. */
166
+ id: string
167
+ name: string
168
+ description: string
169
+ /** System prompt. */
170
+ instructions: string
171
+ /** Model id for this agent; falls back to `defaultModel`. OpenAI only in v1. */
172
+ model?: string
173
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
174
+ tools: readonly AiTool<any, any>[]
175
+ }
176
+
177
+ export interface AiOptions {
178
+ agents: readonly AiAgentDefinition[]
179
+ /** Default OpenAI model id when an agent has none (env AI_MODEL wins if set). */
180
+ defaultModel: string
181
+ /**
182
+ * Convex module paths (relative to convex/) where the app exports the AI
183
+ * functions and the Node runner. Defaults: "ai" and "aiRunner".
184
+ */
185
+ functionsModule?: string
186
+ runnerModule?: string
187
+ }
188
+
189
+ // --- Function names (the app re-exports these) ---------------------------------------
190
+
191
+ /** Public functions in `functionsModule` (default convex/ai.ts). */
192
+ export const AI_PUBLIC_FUNCTIONS = [
193
+ "listAgents", // () → AgentView[]
194
+ "listThreads", // ({ cursor?, limit? }) → { items: ThreadView[], cursor, done }
195
+ "getThread", // ({ threadId }) → ThreadView (NOT_FOUND unless owner)
196
+ "listMessages", // ({ threadId }) → MessageView[] (live, ascending)
197
+ "createThread", // ({ agentId?, title? }) → { threadId }
198
+ "sendMessage", // ({ threadId, text }) → { messageId, runId }; schedules the runner
199
+ "stopRun", // ({ threadId }) → { stopped }
200
+ "respondToToolCall", // ({ messageId, toolCallId, approve }) → { runId }; resumes the run
201
+ "renameThread", // ({ threadId, title })
202
+ "archiveThread", // ({ threadId })
203
+ ] as const
204
+
205
+ /** Internal functions in `functionsModule`, called by the runner. */
206
+ export const AI_INTERNAL_FUNCTIONS = [
207
+ "_startRun", // ({ runId }) → RunContext | null (null: stopped or gone)
208
+ "_appendText", // ({ messageId, text }) append to the last text part (or start one)
209
+ "_addToolCall", // ({ messageId, toolCallId, toolName, args, state })
210
+ "_executeTool", // ({ runId, toolCallId }) → ToolExecutionResult; authorizes and runs the handler
211
+ "_setToolCallState", // ({ messageId, toolCallId, state, result?, error? })
212
+ "_finishRun", // ({ runId, status, error? }) sets run, message and thread status
213
+ "_isStopRequested", // ({ runId }) → boolean
214
+ ] as const
215
+
216
+ /** Node runner in `runnerModule` (default convex/aiRunner.ts, "use node"). */
217
+ export const AI_RUNNER_FUNCTION = "runAgent" // internalAction ({ runId }) → void
218
+
219
+ /** What `_startRun` gives the runner. */
220
+ export interface RunContext {
221
+ runId: AiRunId
222
+ threadId: AiThreadId
223
+ messageId: AiMessageId
224
+ agentId: string
225
+ model: string
226
+ actor: ActorRef
227
+ operationId: string
228
+ /** Full history in order, including this run's assistant message so far. */
229
+ history: MessageView[]
230
+ /** Tool ids of the agent the actor may use right now (permission held). */
231
+ allowedToolIds: string[]
232
+ }
233
+
234
+ export type ToolExecutionResult = { ok: true; result: unknown } | { ok: false; code: string; message: string }
235
+
236
+ // --- Audit actions ------------------------------------------------------------------------
237
+
238
+ export const AI_AUDIT = {
239
+ threadCreate: "ai.thread.create",
240
+ messageSend: "ai.message.send",
241
+ toolRun: "ai.tool.run",
242
+ toolApprove: "ai.tool.approve",
243
+ toolReject: "ai.tool.reject",
244
+ runStop: "ai.run.stop",
245
+ } as const
@@ -0,0 +1,38 @@
1
+ // Display tools of the AI module: the assistant shows the user a visual card
2
+ // (summary, comparison, table, simple chart) made of HTML/CSS/inline SVG.
3
+ //
4
+ // The HTML is model output and therefore UNTRUSTED (prompt injection can reach
5
+ // it through tool results). Apps must render it only with `cardDocument()` in an
6
+ // <iframe sandbox="allow-scripts"> (never allow-same-origin): opaque origin, no
7
+ // network, no navigation, no forms, no popups. Nothing leaves the frame except
8
+ // the height message the frame's own resize script posts.
9
+ import { z } from "zod"
10
+ import { AiPermissions } from "./manifest.js"
11
+ import { AI_CARD_HTML_MAX, AI_CARD_TITLE_MAX } from "./card.js"
12
+ import { defineAiTool } from "./tools.js"
13
+
14
+ export * from "./card.js"
15
+
16
+ export const AI_SHOW_CARD = "ai.show_card"
17
+
18
+ /** `ai.show_card`: no data access, no writes, so no approval; needs only `ai:chat:use`. */
19
+ export const showCardTool = defineAiTool({
20
+ id: AI_SHOW_CARD,
21
+ description: [
22
+ "Show the user a visual card rendered from HTML, inside the chat.",
23
+ "Use it when a visual answer is clearly better than text: a summary with key figures, a comparison, a small table, a status overview, a simple bar chart (HTML/CSS or inline SVG).",
24
+ "Rules: a self-contained HTML fragment (no <html>/<head>), inline <style> allowed; no external resources (images, fonts, scripts, links to URLs are blocked); images only as inline SVG or data: URLs.",
25
+ "Style with the CSS variables provided: var(--background), var(--foreground), var(--muted), var(--muted-foreground), var(--card), var(--border), var(--primary), var(--primary-foreground), var(--destructive), var(--radius). They follow the app's light/dark theme; do not hardcode colours.",
26
+ "The chat already draws the card frame (border, background, padding, title): do NOT add an outer border, background, shadow or padding of your own; start with the content. Use var(--destructive) only for errors or overdue items, var(--primary) for the one figure that matters.",
27
+ "Only show data you got from tools or the user. Keep the card compact. Write a short sentence in the chat too.",
28
+ ].join(" "),
29
+ input: z.object({
30
+ title: z.string().trim().min(1).max(AI_CARD_TITLE_MAX).optional(),
31
+ html: z.string().min(1).max(AI_CARD_HTML_MAX),
32
+ }),
33
+ permission: AiPermissions.use,
34
+ handler: async (_ctx, input) => ({ shown: true, bytes: input.html.length }),
35
+ })
36
+
37
+ /** Tools for showing things to the user. Add to an agent: `tools: [...financeTools, ...displayTools]`. */
38
+ export const displayTools = [showCardTool] as const
@@ -0,0 +1,9 @@
1
+ // `@digimuza/system-core/ai`: reusable AI chat with permissioned tools.
2
+ // Convex default runtime + browser types. The Mastra runner is in
3
+ // `@digimuza/system-core/ai/runner` (Node only, "use node").
4
+ export * from "./contract.js"
5
+ export * from "./manifest.js"
6
+ export { defineAiTool, defineAiAgent, executeAiTool } from "./tools.js"
7
+ export { defineAi, type AiSystem } from "./api.js"
8
+ export { AI_SHOW_CARD, showCardTool, displayTools } from "./display.js"
9
+ export * from "./card.js"
@@ -0,0 +1,16 @@
1
+ import { defineModule, type PermissionOf } from "../modules/index.js"
2
+
3
+ /**
4
+ * The AI assistant module. Business module: registers disabled, a Super
5
+ * enables it. `ai:chat:use` lets a person chat; what the assistant may *do* is
6
+ * decided per tool by that tool's own permission (finance:invoice:read, …).
7
+ */
8
+ export const ai = defineModule({
9
+ id: "ai",
10
+ kind: "business",
11
+ description: "AI asistentas su įrankiais",
12
+ permissions: [{ id: "ai:chat:use", name: "Naudoti asistentą", description: "Kalbėtis su AI asistentu ir matyti savo pokalbius" }],
13
+ initialSubroles: [{ id: "ai:user", name: "Asistento naudotojas", description: "Gali kalbėtis su AI asistentu", permissions: ["ai:chat:use"] }],
14
+ })
15
+
16
+ export const AiPermissions = { use: "ai:chat:use" } as const satisfies Record<string, PermissionOf<typeof ai>>
@@ -0,0 +1,275 @@
1
+ // Node-only Mastra runner of the AI module (DIG-71). Entry `@digimuza/system-core/ai/runner`.
2
+ // All Mastra / AI SDK imports live here, never in contract/api/tools: the app file that
3
+ // re-exports `runAgent` must start with "use node".
4
+ import { createOpenAI } from "@ai-sdk/openai"
5
+ import { Agent } from "@mastra/core/agent"
6
+ import { noopLogger } from "@mastra/core/logger"
7
+ import { createTool } from "@mastra/core/tools"
8
+ import { internalActionGeneric, makeFunctionReference, type GenericActionCtx, type GenericDataModel } from "convex/server"
9
+ import { v } from "convex/values"
10
+ import { createLogger } from "../diagnostics/logging.js"
11
+ import type { AiOptions, AiRunId, AiTool, MessagePart, MessageView, RunContext, RunStatus, ToolCallState, ToolExecutionResult } from "./contract.js"
12
+
13
+ /** Max model steps (calls) per run turn; guards against tool loops. */
14
+ export const AI_MAX_STEPS = 8
15
+ /** Text deltas are written with `_appendText` at most this often … */
16
+ export const AI_FLUSH_MS = 150
17
+ /** … or as soon as this many characters are buffered. */
18
+ export const AI_FLUSH_CHARS = 200
19
+
20
+ // Any AI SDK language model Mastra accepts (V2/V3/V4 spec). Kept loose so the
21
+ // option does not tie apps to one `@ai-sdk/provider` version.
22
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
23
+ export type AiLanguageModel = any
24
+
25
+ export interface AiRunnerOptions extends AiOptions {
26
+ /** OpenAI API key; default `process.env.OPENAI_API_KEY`. Never logged or returned. */
27
+ openAiApiKey?: () => string | undefined
28
+ /**
29
+ * Base URL of an OpenAI-compatible API, e.g. a proxy like 9Router
30
+ * (`https://router.digimuza.ai/v1`). Default `process.env.OPENAI_BASE_URL`,
31
+ * else api.openai.com. Requests go to `<baseURL>/chat/completions`.
32
+ */
33
+ baseURL?: string
34
+ /** Custom fetch for the provider (tests, request middleware). Default: global fetch. */
35
+ fetch?: typeof globalThis.fetch
36
+ /**
37
+ * Test hook: builds the language model for a model id instead of OpenAI (e.g.
38
+ * `MockLanguageModelV4` from `ai/test`). When set, no API key is needed.
39
+ */
40
+ model?: (modelId: string) => AiLanguageModel
41
+ }
42
+
43
+ type ToolPart = Extract<MessagePart, { type: "tool-call" }>
44
+ type ModelMessage =
45
+ | { role: "user"; content: string }
46
+ | { role: "assistant"; content: Array<{ type: "text"; text: string } | { type: "tool-call"; toolCallId: string; toolName: string; input: unknown }> }
47
+ | { role: "tool"; content: Array<{ type: "tool-result"; toolCallId: string; toolName: string; output: { type: "json"; value: unknown } }> }
48
+
49
+ class StopTurn extends Error {}
50
+
51
+ const UNAVAILABLE = "UNAVAILABLE"
52
+ const FAILED = { code: UNAVAILABLE, message: "The assistant failed. Try again." }
53
+
54
+ /** OpenAI function names allow only `[a-zA-Z0-9_-]`; tool ids are `module.verb_object`. */
55
+ export const modelToolName = (toolId: string) => toolId.replace(/[^a-zA-Z0-9_-]/g, "_")
56
+
57
+ const toolOutput = (p: ToolPart): unknown =>
58
+ p.state === "done" ? { ok: true, result: p.result ?? null } : p.state === "rejected" ? { ok: false, error: "REJECTED", message: "rejected by user" } : { ok: false, error: p.error ?? UNAVAILABLE }
59
+
60
+ /**
61
+ * History → model messages. User text → user messages; assistant parts in order as
62
+ * text + tool calls, each finished call (done/error/rejected) followed by its result.
63
+ * Calls without an outcome yet are left out (a call without a result is invalid).
64
+ */
65
+ export function toModelMessages(history: readonly MessageView[]): ModelMessage[] {
66
+ const out: ModelMessage[] = []
67
+ for (const m of history) {
68
+ if (m.role === "user") {
69
+ const text = m.parts.flatMap((p) => (p.type === "text" ? [p.text] : [])).join("\n")
70
+ if (text.trim()) out.push({ role: "user", content: text })
71
+ continue
72
+ }
73
+ let assistant: Extract<ModelMessage, { role: "assistant" }>["content"] = []
74
+ let results: Extract<ModelMessage, { role: "tool" }>["content"] = []
75
+ const flush = () => {
76
+ if (assistant.length) out.push({ role: "assistant", content: assistant })
77
+ if (results.length) out.push({ role: "tool", content: results })
78
+ assistant = []
79
+ results = []
80
+ }
81
+ for (const p of m.parts) {
82
+ if (p.type === "text") {
83
+ if (!p.text) continue
84
+ if (results.length) flush()
85
+ assistant.push({ type: "text", text: p.text })
86
+ } else if (p.state === "done" || p.state === "error" || p.state === "rejected") {
87
+ const toolName = modelToolName(p.toolName)
88
+ assistant.push({ type: "tool-call", toolCallId: p.toolCallId, toolName, input: p.args ?? {} })
89
+ results.push({ type: "tool-result", toolCallId: p.toolCallId, toolName, output: { type: "json", value: toolOutput(p) } })
90
+ }
91
+ }
92
+ flush()
93
+ }
94
+ return out
95
+ }
96
+
97
+ /**
98
+ * // convex/aiRunner.ts
99
+ * "use node"
100
+ * export const { runAgent } = defineAiRunner(aiOptions)
101
+ */
102
+ export function defineAiRunner(options: AiRunnerOptions) {
103
+ const fnModule = options.functionsModule ?? "ai"
104
+ const mutation = (name: string) => makeFunctionReference<"mutation">(`${fnModule}:${name}`)
105
+ const query = (name: string) => makeFunctionReference<"query">(`${fnModule}:${name}`)
106
+ const fn = {
107
+ startRun: mutation("_startRun"),
108
+ appendText: mutation("_appendText"),
109
+ addToolCall: mutation("_addToolCall"),
110
+ executeTool: mutation("_executeTool"),
111
+ setToolCallState: mutation("_setToolCallState"),
112
+ finishRun: mutation("_finishRun"),
113
+ isStopRequested: query("_isStopRequested"),
114
+ }
115
+ const logger = createLogger({ moduleId: "ai.runner" })
116
+
117
+ async function run(ctx: GenericActionCtx<GenericDataModel>, runId: AiRunId): Promise<void> {
118
+ const rc = (await ctx.runMutation(fn.startRun, { runId })) as RunContext | null
119
+ if (!rc) return
120
+
121
+ let finished: RunStatus | null = null
122
+ const finish = async (status: RunStatus, error?: { code: string; message: string }) => {
123
+ if (finished) return
124
+ finished = status
125
+ await ctx.runMutation(fn.finishRun, error ? { runId, status, error } : { runId, status })
126
+ }
127
+
128
+ try {
129
+ const agentDef = options.agents.find((a) => a.id === rc.agentId)
130
+ if (!agentDef) return await finish("error", { code: UNAVAILABLE, message: "Unknown assistant" })
131
+
132
+ let modelFor = options.model
133
+ if (!modelFor) {
134
+ const apiKey = options.openAiApiKey?.() ?? process.env.OPENAI_API_KEY
135
+ if (!apiKey) return await finish("error", { code: UNAVAILABLE, message: "AI model is not configured" })
136
+ const baseURL = options.baseURL ?? (process.env.OPENAI_BASE_URL || undefined)
137
+ const openai = createOpenAI({ apiKey, ...(baseURL ? { baseURL } : {}), ...(options.fetch ? { fetch: options.fetch } : {}) })
138
+ // Chat Completions, not the Responses API default: OpenAI-compatible
139
+ // proxies (9Router, LiteLLM, …) implement /v1/chat/completions. Model ids
140
+ // are plain strings (router names like `cx/gpt-5` are not in the SDK's union).
141
+ modelFor = (id: string) => openai.chat(id)
142
+ }
143
+
144
+ const messageId = rc.messageId
145
+ const executeTool = async (toolCallId: string): Promise<ToolExecutionResult> => {
146
+ let res: ToolExecutionResult
147
+ try {
148
+ res = (await ctx.runMutation(fn.executeTool, { runId, toolCallId })) as ToolExecutionResult
149
+ } catch (e) {
150
+ logger.warn("ai_tool_execute_failed", { errorName: e instanceof Error ? e.name : typeof e })
151
+ res = { ok: false, code: UNAVAILABLE, message: "Tool failed" }
152
+ }
153
+ await ctx.runMutation(
154
+ fn.setToolCallState,
155
+ res.ok ? { messageId, toolCallId, state: "done", result: res.result ?? null } : { messageId, toolCallId, state: "error", error: res.code },
156
+ )
157
+ return res
158
+ }
159
+
160
+ // 5. Resume: run the tools the user approved since the last turn.
161
+ const history = rc.history.map((m) => (m.id === messageId ? { ...m, parts: m.parts.map((p) => ({ ...p })) } : m))
162
+ const current = history.find((m) => m.id === messageId)
163
+ for (const p of current?.parts ?? []) {
164
+ if (p.type !== "tool-call" || p.state !== "approved") continue
165
+ await ctx.runMutation(fn.setToolCallState, { messageId, toolCallId: p.toolCallId, state: "running" })
166
+ const res = await executeTool(p.toolCallId)
167
+ Object.assign(p, res.ok ? { state: "done" as ToolCallState, result: res.result ?? null } : { state: "error" as ToolCallState, error: res.code })
168
+ }
169
+ // Still waiting on the user for another call: nothing to do until they answer.
170
+ if (current?.parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval")) return await finish("waiting-approval")
171
+
172
+ // 7. Text batching + stop checks.
173
+ const controller = new AbortController()
174
+ let buffer = ""
175
+ let lastFlush = Date.now()
176
+ let chain: Promise<void> = Promise.resolve()
177
+ const flush = (): Promise<void> =>
178
+ (chain = chain.then(async () => {
179
+ lastFlush = Date.now()
180
+ if (buffer && !finished) {
181
+ const text = buffer
182
+ buffer = ""
183
+ await ctx.runMutation(fn.appendText, { messageId, text })
184
+ }
185
+ if (!finished && (await ctx.runQuery(fn.isStopRequested, { runId }))) {
186
+ controller.abort()
187
+ await finish("stopped")
188
+ }
189
+ }))
190
+
191
+ // 4. Tools: only those the actor may use right now.
192
+ let awaitingApproval = false
193
+ const allowed = new Set(rc.allowedToolIds)
194
+ const tools: Record<string, ReturnType<typeof createTool>> = {}
195
+ for (const tool of agentDef.tools as readonly AiTool[]) {
196
+ if (!allowed.has(tool.id)) continue
197
+ const name = modelToolName(tool.id)
198
+ tools[name] = createTool({
199
+ id: name,
200
+ description: tool.description,
201
+ inputSchema: tool.input,
202
+ execute: async (input: unknown, toolCtx: { agent?: { toolCallId?: string } } | undefined) => {
203
+ const toolCallId = toolCtx?.agent?.toolCallId || `call_${crypto.randomUUID()}`
204
+ await flush()
205
+ if (finished) throw new StopTurn("stopped")
206
+ const args = input ?? {}
207
+ if (tool.requiresApproval) {
208
+ awaitingApproval = true
209
+ await ctx.runMutation(fn.addToolCall, { messageId, toolCallId, toolName: tool.id, args, state: "awaiting-approval" })
210
+ return { ok: false, status: "awaiting-approval", message: "The user must approve this action first. Stop and wait for their answer." }
211
+ }
212
+ await ctx.runMutation(fn.addToolCall, { messageId, toolCallId, toolName: tool.id, args, state: "running" })
213
+ const res = await executeTool(toolCallId)
214
+ return res.ok ? { ok: true, result: res.result ?? null } : { ok: false, error: res.code, message: res.message }
215
+ },
216
+ } as Parameters<typeof createTool>[0]) as ReturnType<typeof createTool>
217
+ }
218
+
219
+ const agent = new Agent({
220
+ id: agentDef.id,
221
+ name: agentDef.name,
222
+ instructions: agentDef.instructions,
223
+ model: modelFor(rc.model),
224
+ tools,
225
+ })
226
+ // Mastra logs stream errors with the full error object; upstream API errors carry the
227
+ // request body (prompts, tool results). Silence it; we log only the error name.
228
+ agent.__setLogger(noopLogger)
229
+
230
+ const messages = toModelMessages(history)
231
+ // An approval call ends the turn after its step (other calls of the same step still
232
+ // finish and are recorded); the step cap stops tool loops.
233
+ const stopWhen = [({ steps }: { steps: unknown[] }) => steps.length >= AI_MAX_STEPS, () => awaitingApproval]
234
+ try {
235
+ const output = await agent.stream(messages as never, {
236
+ maxSteps: AI_MAX_STEPS,
237
+ stopWhen: stopWhen as never,
238
+ abortSignal: controller.signal,
239
+ })
240
+ const reader = output.fullStream.getReader()
241
+ for (;;) {
242
+ const { done, value } = await reader.read()
243
+ if (done || finished) break
244
+ const chunk = value as { type: string; payload?: { text?: string; error?: unknown } }
245
+ if (chunk.type === "text-delta" && chunk.payload?.text) {
246
+ buffer += chunk.payload.text
247
+ if (buffer.length >= AI_FLUSH_CHARS || Date.now() - lastFlush >= AI_FLUSH_MS) await flush()
248
+ } else if (chunk.type === "error") {
249
+ throw chunk.payload?.error instanceof Error ? chunk.payload.error : new Error("stream error")
250
+ }
251
+ if (finished) break
252
+ }
253
+ if (finished) await reader.cancel().catch(() => {})
254
+ } catch (e) {
255
+ if (!finished) throw e
256
+ }
257
+ await flush()
258
+ await chain
259
+ // 8. End of turn.
260
+ await finish(awaitingApproval ? "waiting-approval" : "done")
261
+ } catch (e) {
262
+ logger.error("ai_run_failed", { errorName: e instanceof Error ? e.name : typeof e })
263
+ await finish("error", FAILED)
264
+ }
265
+ }
266
+
267
+ const runAgent = internalActionGeneric({
268
+ args: { runId: v.id("aiRuns") },
269
+ handler: async (ctx, { runId }) => {
270
+ await run(ctx, runId as AiRunId)
271
+ },
272
+ })
273
+
274
+ return { runAgent }
275
+ }