@vantageos/agent-engine 0.1.0-alpha.4 → 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 (41) hide show
  1. package/README.md +285 -46
  2. package/dist/component/_generated/api.d.ts +31 -0
  3. package/dist/component/_generated/component.d.ts +365 -3
  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 +7 -2
  20. package/dist/component/engine/sessions.js +75 -15
  21. package/dist/component/engine/toolApprovals.d.ts +2 -1
  22. package/dist/component/engine/toolApprovals.js +64 -26
  23. package/dist/component/engine/vpConnection.d.ts +69 -0
  24. package/dist/component/engine/vpConnection.js +221 -0
  25. package/dist/component/engine/wakeup.d.ts +33 -0
  26. package/dist/component/engine/wakeup.js +230 -0
  27. package/dist/component/engine/workflows.d.ts +11 -0
  28. package/dist/component/engine/workflows.js +87 -0
  29. package/dist/component/schema.d.ts +365 -4
  30. package/dist/component/schema.js +360 -1
  31. package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
  32. package/dist/component/testFixtures/delegationDemoWorkflow.js +76 -0
  33. package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +19 -0
  34. package/dist/component/testFixtures/sandboxDemoWorkflow.js +69 -0
  35. package/dist/component/testFixtures/vpTool.d.ts +4 -0
  36. package/dist/component/testFixtures/vpTool.js +25 -0
  37. package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +19 -0
  38. package/dist/component/testFixtures/wakeupDemoWorkflow.js +77 -0
  39. package/dist/component/testFixtures/wakeupWork.d.ts +8 -0
  40. package/dist/component/testFixtures/wakeupWork.js +21 -0
  41. 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">;
@@ -21,6 +25,7 @@ export declare const requestToolCall: import("convex/server").RegisteredMutation
21
25
  }, Promise<{
22
26
  authorized: boolean;
23
27
  approvalPending: boolean;
28
+ denied: boolean;
24
29
  }>>;
25
30
  export declare const completeToolCall: import("convex/server").RegisteredMutation<"public", {
26
31
  orgId: string;
@@ -38,7 +43,7 @@ export declare const listEvents: import("convex/server").RegisteredQuery<"public
38
43
  }, Promise<{
39
44
  _id: import("convex/values").GenericId<"sessionEvents">;
40
45
  _creationTime: number;
41
- name: "session.started" | "turn.started" | "turn.reasoning" | "turn.toolCall.requested" | "turn.toolCall.completed" | "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";
42
47
  payload: string | undefined;
43
48
  createdAt: number;
44
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
@@ -112,7 +146,15 @@ export const requestToolCall = mutation({
112
146
  toolName: v.string(),
113
147
  argsJson: v.string(),
114
148
  },
115
- returns: v.object({ authorized: v.boolean(), approvalPending: v.boolean() }),
149
+ returns: v.object({
150
+ authorized: v.boolean(),
151
+ approvalPending: v.boolean(),
152
+ // Explicit terminal refusal for THIS turn (VP
153
+ // k17ea6x3tht8xa3rxm139t23gx8ajkf3, defaut 2). Distinct from
154
+ // `approvalPending: false, authorized: true` (approved/never-gated)
155
+ // -- a caller must check this before treating the call as skippable.
156
+ denied: v.boolean(),
157
+ }),
116
158
  handler: async (ctx, args) => {
117
159
  const orgId = requireOrgId(args.orgId);
118
160
  const session = await ctx.db.get(args.sessionId);
@@ -132,7 +174,7 @@ export const requestToolCall = mutation({
132
174
  .unique();
133
175
  const approval = approvalRow?.approval ?? "never";
134
176
  if (approval === "never") {
135
- return { authorized: true, approvalPending: false };
177
+ return { authorized: true, approvalPending: false, denied: false };
136
178
  }
137
179
  if (approval === "always") {
138
180
  await insertEvent(ctx, {
@@ -141,20 +183,35 @@ export const requestToolCall = mutation({
141
183
  name: "tool.approval.requested",
142
184
  payload: JSON.stringify({ toolName: args.toolName }),
143
185
  });
144
- return { authorized: false, approvalPending: true };
186
+ return { authorized: false, approvalPending: true, denied: false };
145
187
  }
146
- // approval === "once": gate unless this tool was already resolved once
147
- // in this session.
188
+ // approval === "once": gate unless this tool's approval was already
189
+ // resolved with `decision: "approved"` in this session. A prior
190
+ // `decision: "denied"` resolution must NOT be treated as
191
+ // authorization (that was the pre-alpha.5 bug -- "outil sauté, tour
192
+ // complété quand même") -- it is reported back as a terminal refusal
193
+ // instead, and the caller re-requests (a fresh human decision) rather
194
+ // than silently proceeding.
148
195
  const events = await ctx.db
149
196
  .query("sessionEvents")
150
197
  .withIndex("by_org_session", (q) => q.eq("orgId", orgId).eq("sessionId", args.sessionId))
151
198
  .collect();
152
- const alreadyResolvedThisSession = events.some((e) => e.name === "tool.approval.resolved" &&
199
+ const resolutionsForTool = events
200
+ .filter((e) => e.name === "tool.approval.resolved" &&
153
201
  e.payload !== undefined &&
154
202
  JSON.parse(e.payload).toolName ===
155
- args.toolName);
156
- if (alreadyResolvedThisSession) {
157
- return { authorized: true, approvalPending: false };
203
+ args.toolName)
204
+ .sort((a, b) => a.createdAt - b.createdAt);
205
+ const latestResolution = resolutionsForTool[resolutionsForTool.length - 1];
206
+ const latestDecision = latestResolution?.payload
207
+ ? JSON.parse(latestResolution.payload)
208
+ .decision
209
+ : undefined;
210
+ if (latestDecision === "approved") {
211
+ return { authorized: true, approvalPending: false, denied: false };
212
+ }
213
+ if (latestDecision === "denied") {
214
+ return { authorized: false, approvalPending: false, denied: true };
158
215
  }
159
216
  await insertEvent(ctx, {
160
217
  orgId,
@@ -162,7 +219,7 @@ export const requestToolCall = mutation({
162
219
  name: "tool.approval.requested",
163
220
  payload: JSON.stringify({ toolName: args.toolName }),
164
221
  });
165
- return { authorized: false, approvalPending: true };
222
+ return { authorized: false, approvalPending: true, denied: false };
166
223
  },
167
224
  });
168
225
  // completeToolCall — the caller invokes this after actually running the
@@ -214,7 +271,10 @@ export const listEvents = query({
214
271
  returns: v.array(v.object({
215
272
  _id: v.id("sessionEvents"),
216
273
  _creationTime: v.number(),
217
- 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.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")),
218
278
  payload: v.optional(v.string()),
219
279
  createdAt: v.number(),
220
280
  })),
@@ -1,6 +1,7 @@
1
1
  export declare const respond: import("convex/server").RegisteredMutation<"public", {
2
+ updatePolicy?: "always" | "once" | "never" | undefined;
2
3
  orgId: string;
3
4
  toolName: string;
4
- approval: "always" | "once" | "never";
5
5
  sessionId: import("convex/values").GenericId<"sessions">;
6
+ decision: "approved" | "denied";
6
7
  }, Promise<null>>;
@@ -2,51 +2,89 @@ import { v } from "convex/values";
2
2
  import { mutation } from "../_generated/server.js";
3
3
  import { assertSameOrg, requireOrgId } from "../lib/auth.js";
4
4
  // engine/toolApprovals — respond to a pending approval request
5
- // (`tool.approval.requested`, raised by `sessions.requestToolCall`). The
6
- // decision (always/once/never) is WRITTEN as the agent's standing policy
7
- // for that tool going forward (upsert on `by_org_agent_tool`), and
8
- // `tool.approval.resolved` is appended so `sessions.requestToolCall`'s
9
- // "once" check (and any replaying client) can see it happened.
5
+ // (`tool.approval.requested`, raised by `sessions.requestToolCall`).
6
+ //
7
+ // TWO DISTINCT AXES (VP k17ea6x3tht8xa3rxm139t23gx8ajkf3, defaut 2 --
8
+ // these were conflated pre-alpha.5, which is the defect this fixes):
9
+ //
10
+ // 1. `decision` -- the RESPONSE to THIS ONE pending approval request,
11
+ // for THIS turn only. Vocabulary aligned with Eve's `ApprovalStatus`
12
+ // (packages/eve/src/public/definitions/approval.ts: "approved" |
13
+ // "denied" | ...) -- eve parity, cited verbatim, not "notre ligne".
14
+ // `"denied"` means: this turn's tool call is refused, full stop. It
15
+ // does NOT change the agent's standing policy for the tool, and it
16
+ // must NOT be interpreted as "tool skipped, turn completed anyway" --
17
+ // it appends a `turn.toolCall.denied` sessionEvent instead of letting
18
+ // the caller silently proceed to `completeToolCall`.
19
+ //
20
+ // 2. `updatePolicy` -- optional, persists a NEW standing policy
21
+ // (always | once | never, eve parity: eve/tools/approval helpers) for
22
+ // this (agent, tool) pair going forward, upserted on
23
+ // `by_org_agent_tool`. Independent of `decision`: a human can deny
24
+ // this turn's call AND leave the policy untouched (ask again next
25
+ // time), or approve this turn AND simultaneously flip the policy to
26
+ // "always"/"never" so future calls don't pause.
10
27
  export const respond = mutation({
11
28
  args: {
12
29
  orgId: v.string(),
13
30
  sessionId: v.id("sessions"),
14
31
  toolName: v.string(),
15
- approval: v.union(v.literal("always"), v.literal("once"), v.literal("never")),
32
+ decision: v.union(v.literal("approved"), v.literal("denied")),
33
+ updatePolicy: v.optional(v.union(v.literal("always"), v.literal("once"), v.literal("never"))),
16
34
  },
17
35
  returns: v.null(),
18
36
  handler: async (ctx, args) => {
19
37
  const orgId = requireOrgId(args.orgId);
20
38
  const session = await ctx.db.get(args.sessionId);
21
39
  assertSameOrg(orgId, session, "Session not found");
22
- const existing = await ctx.db
23
- .query("toolApprovals")
24
- .withIndex("by_org_agent_tool", (q) => q
25
- .eq("orgId", orgId)
26
- .eq("agentId", session.agentId)
27
- .eq("toolName", args.toolName))
28
- .unique();
29
40
  const now = Date.now();
30
- if (existing) {
31
- await ctx.db.patch(existing._id, { approval: args.approval, updatedAt: now });
32
- }
33
- else {
34
- await ctx.db.insert("toolApprovals", {
35
- orgId,
36
- agentId: session.agentId,
37
- toolName: args.toolName,
38
- approval: args.approval,
39
- createdAt: now,
40
- updatedAt: now,
41
- });
41
+ if (args.updatePolicy !== undefined) {
42
+ const existing = await ctx.db
43
+ .query("toolApprovals")
44
+ .withIndex("by_org_agent_tool", (q) => q
45
+ .eq("orgId", orgId)
46
+ .eq("agentId", session.agentId)
47
+ .eq("toolName", args.toolName))
48
+ .unique();
49
+ if (existing) {
50
+ await ctx.db.patch(existing._id, {
51
+ approval: args.updatePolicy,
52
+ updatedAt: now,
53
+ });
54
+ }
55
+ else {
56
+ await ctx.db.insert("toolApprovals", {
57
+ orgId,
58
+ agentId: session.agentId,
59
+ toolName: args.toolName,
60
+ approval: args.updatePolicy,
61
+ createdAt: now,
62
+ updatedAt: now,
63
+ });
64
+ }
42
65
  }
43
66
  await ctx.db.insert("sessionEvents", {
44
67
  orgId,
45
68
  sessionId: args.sessionId,
46
69
  name: "tool.approval.resolved",
47
- payload: JSON.stringify({ toolName: args.toolName, approval: args.approval }),
70
+ payload: JSON.stringify({
71
+ toolName: args.toolName,
72
+ decision: args.decision,
73
+ updatedPolicy: args.updatePolicy ?? null,
74
+ }),
48
75
  createdAt: now,
49
76
  });
77
+ if (args.decision === "denied") {
78
+ // Explicit refusal state for THIS turn's tool call -- never a
79
+ // silent "turn.toolCall.completed". See module doc above.
80
+ await ctx.db.insert("sessionEvents", {
81
+ orgId,
82
+ sessionId: args.sessionId,
83
+ name: "turn.toolCall.denied",
84
+ payload: JSON.stringify({ toolName: args.toolName }),
85
+ createdAt: now,
86
+ });
87
+ }
50
88
  return null;
51
89
  },
52
90
  });
@@ -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>>;