@vantageos/agent-engine 0.1.0-alpha.5 → 0.1.0-alpha.7

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 (43) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +154 -473
  3. package/dist/component/_generated/api.d.ts +31 -0
  4. package/dist/component/_generated/component.d.ts +346 -2
  5. package/dist/component/convex.config.js +18 -0
  6. package/dist/component/engine/agents.d.ts +10 -0
  7. package/dist/component/engine/agents.js +21 -0
  8. package/dist/component/engine/awaitEventTimeout.d.ts +13 -0
  9. package/dist/component/engine/awaitEventTimeout.js +81 -0
  10. package/dist/component/engine/delegation.d.ts +75 -0
  11. package/dist/component/engine/delegation.js +327 -0
  12. package/dist/component/engine/directory.d.ts +9 -0
  13. package/dist/component/engine/directory.js +45 -0
  14. package/dist/component/engine/durableJob.js +6 -6
  15. package/dist/component/engine/habits.d.ts +70 -0
  16. package/dist/component/engine/habits.js +218 -0
  17. package/dist/component/engine/mandates.d.ts +74 -0
  18. package/dist/component/engine/mandates.js +271 -0
  19. package/dist/component/engine/sandboxCallback.d.ts +36 -0
  20. package/dist/component/engine/sandboxCallback.js +259 -0
  21. package/dist/component/engine/sessions.d.ts +6 -2
  22. package/dist/component/engine/sessions.js +43 -7
  23. package/dist/component/engine/toolApprovals.js +1 -1
  24. package/dist/component/engine/vpConnection.d.ts +69 -0
  25. package/dist/component/engine/vpConnection.js +218 -0
  26. package/dist/component/engine/wakeup.d.ts +33 -0
  27. package/dist/component/engine/wakeup.js +228 -0
  28. package/dist/component/engine/workflows.d.ts +11 -0
  29. package/dist/component/engine/workflows.js +84 -0
  30. package/dist/component/schema.d.ts +365 -4
  31. package/dist/component/schema.js +345 -18
  32. package/dist/component/testFixtures/callerSteps.js +1 -1
  33. package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
  34. package/dist/component/testFixtures/delegationDemoWorkflow.js +75 -0
  35. package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +19 -0
  36. package/dist/component/testFixtures/sandboxDemoWorkflow.js +69 -0
  37. package/dist/component/testFixtures/vpTool.d.ts +4 -0
  38. package/dist/component/testFixtures/vpTool.js +25 -0
  39. package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +19 -0
  40. package/dist/component/testFixtures/wakeupDemoWorkflow.js +77 -0
  41. package/dist/component/testFixtures/wakeupWork.d.ts +8 -0
  42. package/dist/component/testFixtures/wakeupWork.js +21 -0
  43. package/package.json +17 -5
@@ -0,0 +1,259 @@
1
+ import { WorkflowManager } from "@convex-dev/workflow";
2
+ import { v } from "convex/values";
3
+ import { components, internal } from "../_generated/api.js";
4
+ import { internalMutation, mutation, query } from "../_generated/server.js";
5
+ import { assertSameOrg, requireOrgId } from "../lib/auth.js";
6
+ // engine/sandboxCallback — THE SIGNED CALLBACK GATE'S DB-SIDE LOGIC.
7
+ //
8
+ // BOUNDARY (see the sandbox-bridge header in component/schema.ts for the
9
+ // measured verdict behind each of these):
10
+ // - The public HTTP route (`/sandbox/callback`) that verifies the
11
+ // `SANDBOX_CALLBACK_SECRET` HMAC signature over the raw body CANNOT
12
+ // live in this component (no official `@convex-dev/*` component ships
13
+ // an `http.ts`, and none reads `process.env` directly). It stays in
14
+ // the consumer app's `convex/http.ts`, which reads the secret, verifies
15
+ // the signature, and ONLY THEN calls `processCallback` /
16
+ // `deadLetterMalformed` below.
17
+ // - The "use node" action that talks to `@vercel/sandbox`
18
+ // (`Sandbox.create()` / `sandbox.runCommand()`) CANNOT live in this
19
+ // component either (Convex's own bundler hard-crashes on any "use
20
+ // node" file inside a child component). It stays app-level
21
+ // and calls INTO `startRun` / `attachSandboxId` below (both PUBLIC
22
+ // `mutation`s, not `internalMutation`s, precisely so a cross-component
23
+ // caller can reach them — a component's `internal*` functions are
24
+ // unreachable from outside the component; engine/mandates.ts's
25
+ // `spendInternal` follows the same convention).
26
+ //
27
+ // Everything below assumes the signature already checked out (verified by
28
+ // the caller, upstream of this module) — this module's job is dedup-by-
29
+ // delivery-id + the actual wake, both inside ONE mutation transaction so a
30
+ // replayed delivery can never cause a second `sendEvent`. `orgId` is an
31
+ // explicit ARGUMENT on every public function (component boundaries do not
32
+ // propagate `ctx.auth`, see component/lib/auth.ts).
33
+ //
34
+ // Same WorkflowManager component as engine/wakeup.ts / engine/workflows.ts.
35
+ const workflow = new WorkflowManager(components.workflow);
36
+ const wakeStatus = v.union(v.literal("completed"), v.literal("failed"));
37
+ // startRun — the DB-side half of launching a sandbox run (called by the
38
+ // app-level "use node" action, convex/engine/sandboxBridgeActions.ts,
39
+ // AFTER `engine.mandates.spend` has funded it, same two-transaction
40
+ // protocol as `engine.wakeup.enqueue`). Returns the row id so the caller
41
+ // can inject it into the sandbox's own env (SANDBOX_RUN_ID) before
42
+ // `Sandbox.create()`, and schedules the self-built awaitEvent timeout
43
+ // (workflow#177 pattern, same discipline as engine/awaitEventTimeout.ts)
44
+ // so a sandbox that never calls back — killed by the platform's own
45
+ // ceiling, crashed, or just lost — still wakes the workflow, in FAILURE,
46
+ // rather than leaving it asleep forever.
47
+ export const startRun = mutation({
48
+ args: {
49
+ orgId: v.string(),
50
+ workflowId: v.string(),
51
+ awaitedEventName: v.string(),
52
+ timeoutMs: v.number(),
53
+ },
54
+ returns: v.id("sandboxRuns"),
55
+ handler: async (ctx, args) => {
56
+ const orgId = requireOrgId(args.orgId);
57
+ if (args.workflowId.length === 0) {
58
+ throw new Error("Validation: workflowId must not be empty");
59
+ }
60
+ if (args.awaitedEventName.length === 0) {
61
+ throw new Error("Validation: awaitedEventName must not be empty");
62
+ }
63
+ if (args.timeoutMs <= 0) {
64
+ throw new Error("Validation: timeoutMs must be > 0");
65
+ }
66
+ const now = Date.now();
67
+ const runId = await ctx.db.insert("sandboxRuns", {
68
+ orgId,
69
+ workflowId: args.workflowId,
70
+ awaitedEventName: args.awaitedEventName,
71
+ sandboxId: "", // patched by attachSandboxId once Sandbox.create() resolves
72
+ status: "pending",
73
+ createdAt: now,
74
+ updatedAt: now,
75
+ });
76
+ await ctx.scheduler.runAfter(args.timeoutMs, internal.engine.sandboxCallback.timeoutInternal, {
77
+ sandboxRunId: runId,
78
+ });
79
+ return runId;
80
+ },
81
+ });
82
+ // attachSandboxId — patches the Vercel-assigned sandbox id onto the row
83
+ // once `Sandbox.create()` returns it. Separate step because the row must
84
+ // exist BEFORE the sandbox is created (its id is one of the sandbox's own
85
+ // env vars, so the sandbox can name itself in its callback payload).
86
+ export const attachSandboxId = mutation({
87
+ args: { orgId: v.string(), sandboxRunId: v.id("sandboxRuns"), sandboxId: v.string() },
88
+ returns: v.null(),
89
+ handler: async (ctx, args) => {
90
+ const orgId = requireOrgId(args.orgId);
91
+ const run = await ctx.db.get(args.sandboxRunId);
92
+ assertSameOrg(orgId, run, "Sandbox run not found");
93
+ if (args.sandboxId.length === 0) {
94
+ throw new Error("Validation: sandboxId must not be empty");
95
+ }
96
+ await ctx.db.patch(args.sandboxRunId, {
97
+ sandboxId: args.sandboxId,
98
+ updatedAt: Date.now(),
99
+ });
100
+ return null;
101
+ },
102
+ });
103
+ // getStatus — client-facing read, same org-isolation discipline as every
104
+ // other status query in this engine.
105
+ export const getStatus = query({
106
+ args: { orgId: v.string(), sandboxRunId: v.id("sandboxRuns") },
107
+ returns: v.object({
108
+ status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed"), v.literal("timedOut")),
109
+ resultSummary: v.optional(v.string()),
110
+ }),
111
+ handler: async (ctx, args) => {
112
+ const orgId = requireOrgId(args.orgId);
113
+ const row = await ctx.db.get(args.sandboxRunId);
114
+ assertSameOrg(orgId, row, "Sandbox run not found");
115
+ return { status: row.status, resultSummary: row.resultSummary };
116
+ },
117
+ });
118
+ // deadLetterMalformed — reached from the consumer app's `convex/http.ts`
119
+ // when a SIGNATURE-VALID request's body is not valid JSON or is missing
120
+ // required fields. The signature already proved the caller holds the
121
+ // shared secret, so this is not a security refusal (that already happened
122
+ // upstream, in the app's own HTTP route, before this ever runs) — it is a
123
+ // shape problem worth a human's attention, recorded rather than dropped.
124
+ // `orgId` is optional: the payload may not have parsed far enough to know
125
+ // which org this delivery even claims to belong to.
126
+ export const deadLetterMalformed = mutation({
127
+ args: {
128
+ orgId: v.optional(v.string()),
129
+ deliveryId: v.string(),
130
+ reason: v.string(),
131
+ rawBody: v.string(),
132
+ },
133
+ returns: v.null(),
134
+ handler: async (ctx, args) => {
135
+ await ctx.db.insert("sandboxCallbackDeadLetters", {
136
+ orgId: args.orgId,
137
+ deliveryId: args.deliveryId,
138
+ reason: args.reason,
139
+ rawBody: args.rawBody,
140
+ createdAt: Date.now(),
141
+ });
142
+ return null;
143
+ },
144
+ });
145
+ // processCallback — THE WAKE, reached ONLY from the consumer app's
146
+ // `convex/http.ts` AFTER signature verification. `deliveryId` dedup is the
147
+ // FIRST read in this transaction, and the "woke" delivery row is inserted
148
+ // in the SAME transaction as the `sendEvent` call — Convex mutations are
149
+ // single transactions, so there is no window in which two concurrent
150
+ // replays of the same `deliveryId` could both observe "not yet delivered"
151
+ // and both wake the workflow. A delivery whose signature verified but
152
+ // whose `sandboxRunId` is unknown, cross-org, or already resolved is
153
+ // DEAD-LETTERED rather than silently dropped or blindly retried.
154
+ export const processCallback = mutation({
155
+ args: {
156
+ deliveryId: v.string(),
157
+ orgId: v.string(),
158
+ sandboxRunId: v.string(),
159
+ status: wakeStatus,
160
+ resultSummary: v.optional(v.string()),
161
+ },
162
+ returns: v.object({
163
+ outcome: v.union(v.literal("woke"), v.literal("duplicate"), v.literal("dead-lettered")),
164
+ }),
165
+ handler: async (ctx, args) => {
166
+ const orgId = requireOrgId(args.orgId);
167
+ if (args.deliveryId.length === 0) {
168
+ throw new Error("Validation: deliveryId must not be empty");
169
+ }
170
+ // Dedup FIRST, inside this same transaction.
171
+ const existingDelivery = await ctx.db
172
+ .query("sandboxCallbackDeliveries")
173
+ .withIndex("by_org_delivery", (q) => q.eq("orgId", orgId).eq("deliveryId", args.deliveryId))
174
+ .unique();
175
+ if (existingDelivery) {
176
+ return { outcome: "duplicate" };
177
+ }
178
+ const runId = args.sandboxRunId;
179
+ const run = await ctx.db.get(runId);
180
+ if (!run || run.orgId !== orgId) {
181
+ await ctx.db.insert("sandboxCallbackDeadLetters", {
182
+ orgId,
183
+ deliveryId: args.deliveryId,
184
+ reason: `Unknown or cross-org sandboxRunId "${args.sandboxRunId}"`,
185
+ rawBody: JSON.stringify(args),
186
+ createdAt: Date.now(),
187
+ });
188
+ return { outcome: "dead-lettered" };
189
+ }
190
+ if (run.status !== "pending") {
191
+ // Already resolved (completed/failed/timedOut) — a signature-valid
192
+ // callback for a run that is no longer pending is not a legitimate
193
+ // second wake; record it for human inspection instead of firing
194
+ // sendEvent again or throwing an unreadable error.
195
+ await ctx.db.insert("sandboxCallbackDeadLetters", {
196
+ orgId,
197
+ deliveryId: args.deliveryId,
198
+ reason: `sandboxRun ${run._id} already resolved with status "${run.status}"`,
199
+ rawBody: JSON.stringify(args),
200
+ createdAt: Date.now(),
201
+ });
202
+ return { outcome: "dead-lettered" };
203
+ }
204
+ await ctx.db.insert("sandboxCallbackDeliveries", {
205
+ orgId,
206
+ sandboxRunId: run._id,
207
+ deliveryId: args.deliveryId,
208
+ outcome: "woke",
209
+ receivedAt: Date.now(),
210
+ });
211
+ await ctx.db.patch(run._id, {
212
+ status: args.status,
213
+ resultSummary: args.resultSummary,
214
+ updatedAt: Date.now(),
215
+ });
216
+ await workflow.sendEvent(ctx, {
217
+ workflowId: run.workflowId,
218
+ name: run.awaitedEventName,
219
+ validator: v.object({ status: wakeStatus, resultSummary: v.optional(v.string()) }),
220
+ value: { status: args.status, resultSummary: args.resultSummary },
221
+ });
222
+ return { outcome: "woke" };
223
+ },
224
+ });
225
+ // timeoutInternal — the self-built awaitEvent timeout (workflow#177
226
+ // pattern, same discipline as engine/awaitEventTimeout.ts), scoped to a
227
+ // sandbox run rather than a session. INTERNAL: reached only via
228
+ // `ctx.scheduler.runAfter` from `startRun` above, inside this SAME
229
+ // component — never a cross-component call, so `internalMutation` is
230
+ // correct here (unlike `startRun`/`attachSandboxId`/`processCallback`/
231
+ // `deadLetterMalformed`, which must be reachable from outside). Fires ONLY
232
+ // if the run is still "pending" when the deadline arrives (idempotent
233
+ // against a callback that lands in the same tick); wakes the workflow in a
234
+ // CLEAN FAILURE, not a silent hang.
235
+ export const timeoutInternal = internalMutation({
236
+ args: { sandboxRunId: v.id("sandboxRuns") },
237
+ returns: v.null(),
238
+ handler: async (ctx, args) => {
239
+ const run = await ctx.db.get(args.sandboxRunId);
240
+ if (!run || run.status !== "pending") {
241
+ return null; // already resolved by a real callback, or row gone
242
+ }
243
+ await ctx.db.patch(run._id, {
244
+ status: "timedOut",
245
+ resultSummary: "Sandbox run timed out before any signed callback arrived",
246
+ updatedAt: Date.now(),
247
+ });
248
+ await workflow.sendEvent(ctx, {
249
+ workflowId: run.workflowId,
250
+ name: run.awaitedEventName,
251
+ validator: v.object({ status: wakeStatus, resultSummary: v.optional(v.string()) }),
252
+ value: {
253
+ status: "failed",
254
+ resultSummary: "Sandbox run timed out before any signed callback arrived",
255
+ },
256
+ });
257
+ return null;
258
+ },
259
+ });
@@ -1,8 +1,12 @@
1
+ import type { Id } from "../_generated/dataModel.js";
1
2
  export declare const start: import("convex/server").RegisteredMutation<"public", {
2
3
  orgId: string;
3
4
  createdBy: string;
4
5
  agentId: import("convex/values").GenericId<"agents">;
5
- }, Promise<import("convex/values").GenericId<"sessions">>>;
6
+ }, Promise<{
7
+ sessionId: Id<"sessions">;
8
+ workflowId: string;
9
+ }>>;
6
10
  export declare const sendTurn: import("convex/server").RegisteredMutation<"public", {
7
11
  orgId: string;
8
12
  sessionId: import("convex/values").GenericId<"sessions">;
@@ -39,7 +43,7 @@ export declare const listEvents: import("convex/server").RegisteredQuery<"public
39
43
  }, Promise<{
40
44
  _id: import("convex/values").GenericId<"sessionEvents">;
41
45
  _creationTime: number;
42
- name: "session.started" | "turn.started" | "turn.reasoning" | "turn.toolCall.requested" | "turn.toolCall.completed" | "turn.toolCall.denied" | "turn.completed" | "tool.approval.requested" | "tool.approval.resolved";
46
+ name: "session.started" | "turn.started" | "turn.reasoning" | "turn.toolCall.requested" | "turn.toolCall.completed" | "turn.toolCall.denied" | "turn.completed" | "tool.approval.requested" | "tool.approval.resolved" | "sandbox.timeout";
43
47
  payload: string | undefined;
44
48
  createdAt: number;
45
49
  }[]>>;
@@ -1,7 +1,8 @@
1
1
  import { v } from "convex/values";
2
- import { api } from "../_generated/api.js";
2
+ import { api, internal } from "../_generated/api.js";
3
3
  import { mutation, query } from "../_generated/server.js";
4
4
  import { assertSameOrg, requireOrgId } from "../lib/auth.js";
5
+ import { workflow } from "./workflows.js";
5
6
  // engine/sessions — THE FIL DE CONVERSATION contract (T2-QUINQUIES). This
6
7
  // is what unblocks EveVantage V4: open a session, send a turn, stream
7
8
  // reasoning/tool calls, gate on tool approval, replay the thread on
@@ -23,11 +24,38 @@ async function insertEvent(ctx, args) {
23
24
  createdAt: Date.now(),
24
25
  });
25
26
  }
27
+ const CURRENT_SESSION_WORKFLOW_VERSION = "v1";
26
28
  // start — opens a session (session.started boundary), rereading the
27
- // manifest immediately so a caller can act on it before the first turn.
29
+ // manifest immediately so a caller can act on it before the first turn,
30
+ // then STARTS the durable, versioned session workflow
31
+ // (engine/workflows.ts:sessionWorkflowV1) that pilots this session from
32
+ // here on.
33
+ //
34
+ // RETURN CONTRACT ("intentional divergence from Eve"): `start` returns
35
+ // `{ sessionId, workflowId }` instead of a bare `sessionId`. A bare ID
36
+ // would be the shape for a session with no workflow to reference;
37
+ // once a session IS piloted by a real, durable workflow run, silently
38
+ // dropping that run's id would make it unobservable/uncancellable to
39
+ // every caller except one that re-reads the session row. Returning both
40
+ // fields explicitly, rather than only patching `workflowId` onto the row
41
+ // and making callers re-fetch, keeps `start`'s result self-sufficient for
42
+ // the common "immediately act on the new session" case while the session
43
+ // row remains the durable source of truth for reconnects/replays.
28
44
  export const start = mutation({
29
45
  args: { orgId: v.string(), agentId: v.id("agents"), createdBy: v.string() },
30
- returns: v.id("sessions"),
46
+ returns: v.object({ sessionId: v.id("sessions"), workflowId: v.string() }),
47
+ // EXPLICIT return-type annotation ("intentional divergence from Eve" -- Convex pattern for
48
+ // any handler that references `components`/a WorkflowManager built on
49
+ // `components.workflow`): `workflow.start` closes over `components`,
50
+ // which closes back over this component's own generated `api` --
51
+ // `sessions.start` -> `workflow.start` -> `components` -> `sessions` is
52
+ // a type-level cycle. Without an explicit handler return type, TS
53
+ // widens the inferred return to `any` while resolving that cycle, and
54
+ // `.d.ts` EMISSION (declaration:true, tsconfig.build.json) fails --
55
+ // TS2502/TS2615/TS7022 -- even though `tsc --noEmit` at the repo root
56
+ // stays green (noEmit skips the exact codepath that breaks). Measured
57
+ // on PR #13 (build gate review): `npm run build` is the build that SHIPS; a
58
+ // green `tsc --noEmit` is not sufficient proof by itself.
31
59
  handler: async (ctx, args) => {
32
60
  const orgId = requireOrgId(args.orgId);
33
61
  if (args.createdBy.trim().length === 0) {
@@ -49,7 +77,12 @@ export const start = mutation({
49
77
  orgId,
50
78
  sessionId,
51
79
  });
52
- return sessionId;
80
+ const workflowId = await workflow.start(ctx, internal.engine.workflows.sessionWorkflowV1, { sessionId });
81
+ await ctx.db.patch(sessionId, {
82
+ workflowId,
83
+ workflowVersion: CURRENT_SESSION_WORKFLOW_VERSION,
84
+ });
85
+ return { sessionId, workflowId };
53
86
  },
54
87
  });
55
88
  // sendTurn — turn.started boundary. Rereads the manifest (eve parity: dynamic
@@ -154,8 +187,8 @@ export const requestToolCall = mutation({
154
187
  // approval === "once": gate unless this tool's approval was already
155
188
  // resolved with `decision: "approved"` in this session. A prior
156
189
  // `decision: "denied"` resolution must NOT be treated as
157
- // authorization (that was the pre-alpha.5 bug -- "outil sauté, tour
158
- // complété quand même") -- it is reported back as a terminal refusal
190
+ // authorization (the tool would be skipped while the turn still
191
+ // completed) -- it is reported back as a terminal refusal
159
192
  // instead, and the caller re-requests (a fresh human decision) rather
160
193
  // than silently proceeding.
161
194
  const events = await ctx.db
@@ -237,7 +270,10 @@ export const listEvents = query({
237
270
  returns: v.array(v.object({
238
271
  _id: v.id("sessionEvents"),
239
272
  _creationTime: v.number(),
240
- name: v.union(v.literal("session.started"), v.literal("turn.started"), v.literal("turn.reasoning"), v.literal("turn.toolCall.requested"), v.literal("turn.toolCall.completed"), v.literal("turn.toolCall.denied"), v.literal("turn.completed"), v.literal("tool.approval.requested"), v.literal("tool.approval.resolved")),
273
+ name: v.union(v.literal("session.started"), v.literal("turn.started"), v.literal("turn.reasoning"), v.literal("turn.toolCall.requested"), v.literal("turn.toolCall.completed"), v.literal("turn.toolCall.denied"), v.literal("turn.completed"), v.literal("tool.approval.requested"), v.literal("tool.approval.resolved"),
274
+ // Blank event auto-inserted by
275
+ // `engine/awaitEventTimeout.fire` (workflow#177).
276
+ v.literal("sandbox.timeout")),
241
277
  payload: v.optional(v.string()),
242
278
  createdAt: v.number(),
243
279
  })),
@@ -10,7 +10,7 @@ import { assertSameOrg, requireOrgId } from "../lib/auth.js";
10
10
  // 1. `decision` -- the RESPONSE to THIS ONE pending approval request,
11
11
  // for THIS turn only. Vocabulary aligned with Eve's `ApprovalStatus`
12
12
  // (packages/eve/src/public/definitions/approval.ts: "approved" |
13
- // "denied" | ...) -- eve parity, cited verbatim, not "notre ligne".
13
+ // "denied" | ...) -- eve parity, cited verbatim, not "intentional divergence from Eve".
14
14
  // `"denied"` means: this turn's tool call is refused, full stop. It
15
15
  // does NOT change the agent's standing policy for the tool, and it
16
16
  // must NOT be interpreted as "tool skipped, turn completed anyway" --
@@ -0,0 +1,69 @@
1
+ export declare const defaultToggles: {
2
+ readonly memoryEnabled: true;
3
+ readonly tasksEnabled: true;
4
+ readonly messagesEnabled: true;
5
+ readonly notesEnabled: true;
6
+ };
7
+ export declare const get: import("convex/server").RegisteredQuery<"public", {
8
+ orgId: string;
9
+ agentId: import("convex/values").GenericId<"agents">;
10
+ }, Promise<{
11
+ _id: import("convex/values").GenericId<"vpConnections">;
12
+ _creationTime: number;
13
+ orgId: string;
14
+ createdAt: number;
15
+ updatedAt: number;
16
+ agentId: import("convex/values").GenericId<"agents">;
17
+ memoryEnabled: boolean;
18
+ tasksEnabled: boolean;
19
+ messagesEnabled: boolean;
20
+ notesEnabled: boolean;
21
+ } | null>>;
22
+ export declare const getToggles: import("convex/server").RegisteredQuery<"public", {
23
+ orgId: string;
24
+ agentId: import("convex/values").GenericId<"agents">;
25
+ }, Promise<{
26
+ readonly memoryEnabled: true;
27
+ readonly tasksEnabled: true;
28
+ readonly messagesEnabled: true;
29
+ readonly notesEnabled: true;
30
+ } | {
31
+ memoryEnabled: boolean;
32
+ tasksEnabled: boolean;
33
+ messagesEnabled: boolean;
34
+ notesEnabled: boolean;
35
+ }>>;
36
+ export declare const setToggles: import("convex/server").RegisteredMutation<"public", {
37
+ memoryEnabled?: boolean | undefined;
38
+ tasksEnabled?: boolean | undefined;
39
+ messagesEnabled?: boolean | undefined;
40
+ notesEnabled?: boolean | undefined;
41
+ orgId: string;
42
+ agentId: import("convex/values").GenericId<"agents">;
43
+ }, Promise<import("convex/values").GenericId<"vpConnections">>>;
44
+ export declare const invokeVpTool: import("convex/server").RegisteredAction<"public", {
45
+ orgId: string;
46
+ agentId: import("convex/values").GenericId<"agents">;
47
+ tool: "memory" | "tasks" | "messages" | "notes";
48
+ argsJson: string;
49
+ vpFunctionHandle: string;
50
+ }, Promise<null>>;
51
+ export declare const getTogglesInternal: import("convex/server").RegisteredQuery<"internal", {
52
+ orgId: string;
53
+ agentId: import("convex/values").GenericId<"agents">;
54
+ }, Promise<{
55
+ readonly memoryEnabled: true;
56
+ readonly tasksEnabled: true;
57
+ readonly messagesEnabled: true;
58
+ readonly notesEnabled: true;
59
+ } | {
60
+ memoryEnabled: boolean;
61
+ tasksEnabled: boolean;
62
+ messagesEnabled: boolean;
63
+ notesEnabled: boolean;
64
+ } | null>>;
65
+ export declare const recordInvocationInternal: import("convex/server").RegisteredMutation<"internal", {
66
+ orgId: string;
67
+ agentId: import("convex/values").GenericId<"agents">;
68
+ tool: "memory" | "tasks" | "messages" | "notes";
69
+ }, Promise<null>>;
@@ -0,0 +1,218 @@
1
+ import { v } from "convex/values";
2
+ import { action, internalMutation, internalQuery, mutation, query, } from "../_generated/server.js";
3
+ import { assertSameOrg, requireOrgId } from "../lib/auth.js";
4
+ import { internal } from "../_generated/api.js";
5
+ // engine/vpConnection — teamwork. The four VantagePeers tools
6
+ // (memory, tasks, messages, notes) are wired as a CONNECTION of the agent
7
+ // aggregate -- intentional divergence from Eve, same slot family as `connections` (MCP/OpenAPI).
8
+ // Four toggles, ON by default.
9
+ //
10
+ // DIRECT ACCESS, NO NETWORK IN THE HOT PATH: `invokeVpTool` dispatches via a
11
+ // caller-supplied Convex function handle (`createFunctionHandle`), the same
12
+ // discipline `engine/wakeup.ts` and `engine/durableJob.ts` already use for
13
+ // `workFunctionHandle` / `stepFunctionHandle` -- an in-process function
14
+ // reference resolved by `ctx.runMutation`, never an HTTP request: this file
15
+ // contains zero occurrences of the literal fetch call.
16
+ //
17
+ // orgId discipline (component/lib/auth.ts): every PUBLIC function takes
18
+ // `orgId` as an explicit ARGUMENT (auth does not propagate across a
19
+ // component boundary). `orgId` is passed EXPLICITLY to the target function
20
+ // handle (never trusting the target's own `ctx.auth`) -- this is the
21
+ // isolation guarantee the org-scoped write depends on: the target function
22
+ // is expected to write scoped to the `orgId` it was HANDED, not to derive
23
+ // one itself.
24
+ const vpTool = v.union(v.literal("memory"), v.literal("tasks"), v.literal("messages"), v.literal("notes"));
25
+ // defaultToggles — four switches, all ticked by default. A
26
+ // missing `vpConnections` row means "use these defaults", so every agent
27
+ // starts with all four VP tools available with zero extra setup.
28
+ export const defaultToggles = {
29
+ memoryEnabled: true,
30
+ tasksEnabled: true,
31
+ messagesEnabled: true,
32
+ notesEnabled: true,
33
+ };
34
+ const vpConnectionDoc = v.object({
35
+ _id: v.id("vpConnections"),
36
+ _creationTime: v.number(),
37
+ orgId: v.string(),
38
+ agentId: v.id("agents"),
39
+ memoryEnabled: v.boolean(),
40
+ tasksEnabled: v.boolean(),
41
+ messagesEnabled: v.boolean(),
42
+ notesEnabled: v.boolean(),
43
+ createdAt: v.number(),
44
+ updatedAt: v.number(),
45
+ });
46
+ // get — the raw connection row, if a human ever touched the toggles.
47
+ export const get = query({
48
+ args: { orgId: v.string(), agentId: v.id("agents") },
49
+ returns: v.union(vpConnectionDoc, v.null()),
50
+ handler: async (ctx, args) => {
51
+ const orgId = requireOrgId(args.orgId);
52
+ const agent = await ctx.db.get(args.agentId);
53
+ assertSameOrg(orgId, agent, "Agent not found");
54
+ return await ctx.db
55
+ .query("vpConnections")
56
+ .withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
57
+ .unique();
58
+ },
59
+ });
60
+ // getToggles — resolves the effective toggle state (row if present, else
61
+ // the checked-by-default set) for a given agent.
62
+ export const getToggles = query({
63
+ args: { orgId: v.string(), agentId: v.id("agents") },
64
+ returns: v.object({
65
+ memoryEnabled: v.boolean(),
66
+ tasksEnabled: v.boolean(),
67
+ messagesEnabled: v.boolean(),
68
+ notesEnabled: v.boolean(),
69
+ }),
70
+ handler: async (ctx, args) => {
71
+ const orgId = requireOrgId(args.orgId);
72
+ const agent = await ctx.db.get(args.agentId);
73
+ assertSameOrg(orgId, agent, "Agent not found");
74
+ const row = await ctx.db
75
+ .query("vpConnections")
76
+ .withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
77
+ .unique();
78
+ return row
79
+ ? {
80
+ memoryEnabled: row.memoryEnabled,
81
+ tasksEnabled: row.tasksEnabled,
82
+ messagesEnabled: row.messagesEnabled,
83
+ notesEnabled: row.notesEnabled,
84
+ }
85
+ : defaultToggles;
86
+ },
87
+ });
88
+ // setToggles — upserts the four switches (an explicit human choice to turn
89
+ // one off; still ON by default until touched).
90
+ export const setToggles = mutation({
91
+ args: {
92
+ orgId: v.string(),
93
+ agentId: v.id("agents"),
94
+ memoryEnabled: v.optional(v.boolean()),
95
+ tasksEnabled: v.optional(v.boolean()),
96
+ messagesEnabled: v.optional(v.boolean()),
97
+ notesEnabled: v.optional(v.boolean()),
98
+ },
99
+ returns: v.id("vpConnections"),
100
+ handler: async (ctx, args) => {
101
+ const orgId = requireOrgId(args.orgId);
102
+ const agent = await ctx.db.get(args.agentId);
103
+ assertSameOrg(orgId, agent, "Agent not found");
104
+ const existing = await ctx.db
105
+ .query("vpConnections")
106
+ .withIndex("by_org_agent", (q) => q.eq("orgId", orgId).eq("agentId", args.agentId))
107
+ .unique();
108
+ const now = Date.now();
109
+ const merged = {
110
+ memoryEnabled: args.memoryEnabled ?? existing?.memoryEnabled ?? defaultToggles.memoryEnabled,
111
+ tasksEnabled: args.tasksEnabled ?? existing?.tasksEnabled ?? defaultToggles.tasksEnabled,
112
+ messagesEnabled: args.messagesEnabled ?? existing?.messagesEnabled ?? defaultToggles.messagesEnabled,
113
+ notesEnabled: args.notesEnabled ?? existing?.notesEnabled ?? defaultToggles.notesEnabled,
114
+ };
115
+ if (existing) {
116
+ await ctx.db.patch(existing._id, { ...merged, updatedAt: now });
117
+ return existing._id;
118
+ }
119
+ return await ctx.db.insert("vpConnections", {
120
+ orgId,
121
+ agentId: args.agentId,
122
+ ...merged,
123
+ createdAt: now,
124
+ updatedAt: now,
125
+ });
126
+ },
127
+ });
128
+ function toggleFor(toggles, tool) {
129
+ return tool === "memory"
130
+ ? toggles.memoryEnabled
131
+ : tool === "tasks"
132
+ ? toggles.tasksEnabled
133
+ : tool === "messages"
134
+ ? toggles.messagesEnabled
135
+ : toggles.notesEnabled;
136
+ }
137
+ // invokeVpTool — the direct-access dispatch. `vpFunctionHandle` is an
138
+ // opaque handle string obtained via `createFunctionHandle` against a
139
+ // mutation the VantagePeers tenant exposes (same-app or cross-app handle,
140
+ // per Convex's own `createFunctionHandle` contract -- never a URL, never an
141
+ // HTTP fetch call). The engine passes `orgId` EXPLICITLY as an argument
142
+ // (never trusting the target's own `ctx.auth`, since this may cross an
143
+ // app/component boundary) -- this is the isolation guarantee the org-scoped
144
+ // write depends on: the target function is expected to write scoped to the
145
+ // `orgId` it was HANDED, not to derive one itself.
146
+ export const invokeVpTool = action({
147
+ args: {
148
+ orgId: v.string(),
149
+ agentId: v.id("agents"),
150
+ tool: vpTool,
151
+ vpFunctionHandle: v.string(),
152
+ argsJson: v.string(),
153
+ },
154
+ returns: v.null(),
155
+ handler: async (ctx, args) => {
156
+ const orgId = requireOrgId(args.orgId);
157
+ const toggles = await ctx.runQuery(internal.engine.vpConnection.getTogglesInternal, {
158
+ orgId,
159
+ agentId: args.agentId,
160
+ });
161
+ if (!toggles) {
162
+ throw new Error("Agent not found");
163
+ }
164
+ if (!toggleFor(toggles, args.tool)) {
165
+ throw new Error(`VantagePeers tool "${args.tool}" is disabled for this agent`);
166
+ }
167
+ // In-process dispatch by function handle -- NOT an HTTP fetch call, NOT
168
+ // an HTTP hop. `orgId` is threaded explicitly so the write lands in the
169
+ // CALLING agent's org tenant regardless of which org's identity (if
170
+ // any) the target function's own `ctx.auth` would otherwise resolve.
171
+ await ctx.runMutation(args.vpFunctionHandle, { orgId, argsJson: args.argsJson });
172
+ await ctx.runMutation(internal.engine.vpConnection.recordInvocationInternal, {
173
+ orgId,
174
+ agentId: args.agentId,
175
+ tool: args.tool,
176
+ });
177
+ return null;
178
+ },
179
+ });
180
+ export const getTogglesInternal = internalQuery({
181
+ args: { orgId: v.string(), agentId: v.id("agents") },
182
+ returns: v.union(v.object({
183
+ memoryEnabled: v.boolean(),
184
+ tasksEnabled: v.boolean(),
185
+ messagesEnabled: v.boolean(),
186
+ notesEnabled: v.boolean(),
187
+ }), v.null()),
188
+ handler: async (ctx, args) => {
189
+ const agent = await ctx.db.get(args.agentId);
190
+ if (!agent || agent.orgId !== args.orgId)
191
+ return null;
192
+ const row = await ctx.db
193
+ .query("vpConnections")
194
+ .withIndex("by_org_agent", (q) => q.eq("orgId", args.orgId).eq("agentId", args.agentId))
195
+ .unique();
196
+ return row
197
+ ? {
198
+ memoryEnabled: row.memoryEnabled,
199
+ tasksEnabled: row.tasksEnabled,
200
+ messagesEnabled: row.messagesEnabled,
201
+ notesEnabled: row.notesEnabled,
202
+ }
203
+ : defaultToggles;
204
+ },
205
+ });
206
+ export const recordInvocationInternal = internalMutation({
207
+ args: { orgId: v.string(), agentId: v.id("agents"), tool: vpTool },
208
+ returns: v.null(),
209
+ handler: async (ctx, args) => {
210
+ await ctx.db.insert("vpToolInvocations", {
211
+ orgId: args.orgId,
212
+ agentId: args.agentId,
213
+ tool: args.tool,
214
+ createdAt: Date.now(),
215
+ });
216
+ return null;
217
+ },
218
+ });