@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.
- package/README.md +285 -46
- package/dist/component/_generated/api.d.ts +31 -0
- package/dist/component/_generated/component.d.ts +365 -3
- package/dist/component/convex.config.js +19 -0
- package/dist/component/engine/agents.d.ts +10 -0
- package/dist/component/engine/agents.js +21 -0
- package/dist/component/engine/awaitEventTimeout.d.ts +13 -0
- package/dist/component/engine/awaitEventTimeout.js +82 -0
- package/dist/component/engine/delegation.d.ts +75 -0
- package/dist/component/engine/delegation.js +329 -0
- package/dist/component/engine/directory.d.ts +9 -0
- package/dist/component/engine/directory.js +45 -0
- package/dist/component/engine/habits.d.ts +70 -0
- package/dist/component/engine/habits.js +219 -0
- package/dist/component/engine/mandates.d.ts +74 -0
- package/dist/component/engine/mandates.js +272 -0
- package/dist/component/engine/sandboxCallback.d.ts +36 -0
- package/dist/component/engine/sandboxCallback.js +262 -0
- package/dist/component/engine/sessions.d.ts +7 -2
- package/dist/component/engine/sessions.js +75 -15
- package/dist/component/engine/toolApprovals.d.ts +2 -1
- package/dist/component/engine/toolApprovals.js +64 -26
- package/dist/component/engine/vpConnection.d.ts +69 -0
- package/dist/component/engine/vpConnection.js +221 -0
- package/dist/component/engine/wakeup.d.ts +33 -0
- package/dist/component/engine/wakeup.js +230 -0
- package/dist/component/engine/workflows.d.ts +11 -0
- package/dist/component/engine/workflows.js +87 -0
- package/dist/component/schema.d.ts +365 -4
- package/dist/component/schema.js +360 -1
- package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
- package/dist/component/testFixtures/delegationDemoWorkflow.js +76 -0
- package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +19 -0
- package/dist/component/testFixtures/sandboxDemoWorkflow.js +69 -0
- package/dist/component/testFixtures/vpTool.d.ts +4 -0
- package/dist/component/testFixtures/vpTool.js +25 -0
- package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +19 -0
- package/dist/component/testFixtures/wakeupDemoWorkflow.js +77 -0
- package/dist/component/testFixtures/wakeupWork.d.ts +8 -0
- package/dist/component/testFixtures/wakeupWork.js +21 -0
- 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<
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
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
|
-
|
|
157
|
-
|
|
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`).
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
|
|
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 (
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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({
|
|
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>>;
|