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

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