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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +110 -0
  2. package/README.md +154 -473
  3. package/dist/component/_generated/api.d.ts +31 -0
  4. package/dist/component/_generated/component.d.ts +346 -2
  5. package/dist/component/convex.config.js +18 -0
  6. package/dist/component/engine/agents.d.ts +10 -0
  7. package/dist/component/engine/agents.js +21 -0
  8. package/dist/component/engine/awaitEventTimeout.d.ts +13 -0
  9. package/dist/component/engine/awaitEventTimeout.js +81 -0
  10. package/dist/component/engine/delegation.d.ts +75 -0
  11. package/dist/component/engine/delegation.js +327 -0
  12. package/dist/component/engine/directory.d.ts +9 -0
  13. package/dist/component/engine/directory.js +45 -0
  14. package/dist/component/engine/durableJob.js +6 -6
  15. package/dist/component/engine/habits.d.ts +70 -0
  16. package/dist/component/engine/habits.js +218 -0
  17. package/dist/component/engine/mandates.d.ts +74 -0
  18. package/dist/component/engine/mandates.js +271 -0
  19. package/dist/component/engine/sandboxCallback.d.ts +36 -0
  20. package/dist/component/engine/sandboxCallback.js +259 -0
  21. package/dist/component/engine/sessions.d.ts +6 -2
  22. package/dist/component/engine/sessions.js +43 -7
  23. package/dist/component/engine/toolApprovals.js +1 -1
  24. package/dist/component/engine/vpConnection.d.ts +69 -0
  25. package/dist/component/engine/vpConnection.js +218 -0
  26. package/dist/component/engine/wakeup.d.ts +33 -0
  27. package/dist/component/engine/wakeup.js +228 -0
  28. package/dist/component/engine/workflows.d.ts +11 -0
  29. package/dist/component/engine/workflows.js +84 -0
  30. package/dist/component/schema.d.ts +365 -4
  31. package/dist/component/schema.js +345 -18
  32. package/dist/component/testFixtures/callerSteps.js +1 -1
  33. package/dist/component/testFixtures/delegationDemoWorkflow.d.ts +18 -0
  34. package/dist/component/testFixtures/delegationDemoWorkflow.js +75 -0
  35. package/dist/component/testFixtures/sandboxDemoWorkflow.d.ts +19 -0
  36. package/dist/component/testFixtures/sandboxDemoWorkflow.js +69 -0
  37. package/dist/component/testFixtures/vpTool.d.ts +4 -0
  38. package/dist/component/testFixtures/vpTool.js +25 -0
  39. package/dist/component/testFixtures/wakeupDemoWorkflow.d.ts +19 -0
  40. package/dist/component/testFixtures/wakeupDemoWorkflow.js +77 -0
  41. package/dist/component/testFixtures/wakeupWork.d.ts +8 -0
  42. package/dist/component/testFixtures/wakeupWork.js +21 -0
  43. package/package.json +17 -5
@@ -0,0 +1,33 @@
1
+ import { Workpool } from "@convex-dev/workpool";
2
+ import type { Id } from "../_generated/dataModel.js";
3
+ export declare const wakeupPool: Workpool;
4
+ export declare const enqueue: import("convex/server").RegisteredAction<"public", {
5
+ payload?: string | undefined;
6
+ orgId: string;
7
+ workflowId: string;
8
+ agentId: import("convex/values").GenericId<"agents">;
9
+ awaitedEventName: string;
10
+ scope: string;
11
+ reason: string;
12
+ cost: number;
13
+ workFunctionHandle: string;
14
+ }, Promise<Id<"wakeableWork">>>;
15
+ export declare const enqueueInternal: import("convex/server").RegisteredMutation<"internal", {
16
+ payload?: string | undefined;
17
+ orgId: string;
18
+ workflowId: string;
19
+ awaitedEventName: string;
20
+ workFunctionHandle: string;
21
+ }, Promise<Id<"wakeableWork">>>;
22
+ export declare const cancel: import("convex/server").RegisteredMutation<"public", {
23
+ orgId: string;
24
+ wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
25
+ }, Promise<null>>;
26
+ export declare const getStatus: import("convex/server").RegisteredQuery<"public", {
27
+ orgId: string;
28
+ wakeableWorkId: import("convex/values").GenericId<"wakeableWork">;
29
+ }, Promise<{
30
+ status: "completed" | "cancelled" | "pending" | "failed";
31
+ resultSummary: string | undefined;
32
+ }>>;
33
+ export declare const onWorkComplete: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workpool").OnCompleteArgs, null>;
@@ -0,0 +1,228 @@
1
+ import { Workpool } from "@convex-dev/workpool";
2
+ import { v } from "convex/values";
3
+ import { WorkflowManager } from "@convex-dev/workflow";
4
+ import { components, internal } from "../_generated/api.js";
5
+ import { action, internalMutation, mutation, query } from "../_generated/server.js";
6
+ import { assertSameOrg, requireOrgId } from "../lib/auth.js";
7
+ // engine/wakeup — THE GENERIC WAKE-UP PRIMITIVE. An agent does not ask every
8
+ // minute whether work has arrived: it is woken the instant the work resolves.
9
+ // Mechanism: `enqueue` puts caller-supplied work (an action, referenced by
10
+ // an opaque handle string
11
+ // from `createFunctionHandle` in the CALLER's own app, same
12
+ // component-isolation discipline as `durableJob.stepFunctionHandle`) into a
13
+ // dedicated `@convex-dev/workpool` (`components.wakeupPool`, declared in
14
+ // component/convex.config.ts). That pool's `onComplete` callback -- which
15
+ // fires in its OWN transaction once the work resolves (workpool issue #133:
16
+ // onComplete runs in a separate transaction; there is no native pause) --
17
+ // calls `workflow.sendEvent(...)` against the CALLER-supplied
18
+ // `workflowId`. Any workflow step parked on
19
+ // `step.awaitEvent({ name: awaitedEventName })` resumes the INSTANT that
20
+ // event lands -- no interval, no cron, no re-query loop. This fires for
21
+ // success, failure, AND cancellation alike: see `onWorkComplete` below -- a
22
+ // sleeping agent whose work silently died is worse than a polling one.
23
+ //
24
+ // Decoupled from `sessions`/`durableJobs` by design: any workflow (a
25
+ // session, a durable job, or a bespoke one) can be the wake target by
26
+ // handing us its own `workflowId` -- this table/module never reads either
27
+ // of those tables.
28
+ //
29
+ // ---------------------------------------------------------------------
30
+ // "intentional divergence from Eve"
31
+ // ---------------------------------------------------------------------
32
+ //
33
+ // (A) orgId is an EXPLICIT ARGUMENT on every public function, never derived
34
+ // from `ctx.auth` -- auth does NOT propagate across a component boundary
35
+ // (see component/lib/auth.ts). The consumer app has already authenticated
36
+ // its caller and resolved orgId on its OWN side before calling in.
37
+ //
38
+ // (B) MANDATE FUNDING CHOKE-POINT. `enqueue` is an
39
+ // `action` because funding-then-starting is a TWO-TRANSACTION protocol a
40
+ // single mutation cannot express: `mandates.spendInternal` must be able to
41
+ // COMMIT an `approvalRequests` row on an overrun WITHOUT that commit being
42
+ // rolled back by a subsequent throw in the same transaction (see the
43
+ // transactional argument in `component/engine/mandates.ts:spendCore`,
44
+ // proven by the MUST_BLOCK probe in `component/engine/mandates.test.ts`).
45
+ // The action:
46
+ // 1. validates `orgId` (the ARGUMENT the caller supplies -- see note A;
47
+ // this component never reads `ctx.auth`),
48
+ // 2. runs `mandates.spendInternal` in its OWN transaction
49
+ // (`ctx.runMutation`),
50
+ // 3. throws immediately if `!result.ok` -- refuses end-to-end. The
51
+ // approval-request row from step 2 has ALREADY committed, because it
52
+ // lives in a transaction that already returned before this throw runs.
53
+ // 4. only on `result.ok === true` does it call `enqueueInternal` in a
54
+ // SECOND transaction to actually put the work in the pool.
55
+ // There is no code path from "client wants work started" to "work enters
56
+ // the pool" that does not pass through step 2 -- this is an "argument you
57
+ // cannot fabricate" pattern: the only way to make
58
+ // `enqueueInternal` run at all is to be `enqueue` itself, and `enqueue`
59
+ // always calls `mandates.spendInternal` first. `spendInternal` takes
60
+ // `orgId` as an argument rather than deriving it from `ctx.auth`, per the
61
+ // boundary discipline in note A.
62
+ export const wakeupPool = new Workpool(components.wakeupPool, {
63
+ maxParallelism: 10,
64
+ });
65
+ // Same WorkflowManager COMPONENT as engine/workflows.ts (components.workflow)
66
+ // -- a fresh WorkflowManager instance bound to the same component is safe
67
+ // to construct per-module; the manager itself holds no local state, it
68
+ // only forwards to the component.
69
+ const workflow = new WorkflowManager(components.workflow);
70
+ // ---------------------------------------------------------------------
71
+ // PUBLIC CONTRACT
72
+ // ---------------------------------------------------------------------
73
+ // enqueue — the ONLY client-facing entry point (an `action`, see note B
74
+ // above). Funds the work through `mandates.spendInternal` in its
75
+ // own transaction FIRST; only calls `enqueueInternal` (a second,
76
+ // independent transaction) once that funding succeeded.
77
+ export const enqueue = action({
78
+ args: {
79
+ orgId: v.string(),
80
+ agentId: v.id("agents"),
81
+ scope: v.string(),
82
+ cost: v.number(),
83
+ reason: v.string(),
84
+ workflowId: v.string(),
85
+ awaitedEventName: v.string(),
86
+ workFunctionHandle: v.string(),
87
+ payload: v.optional(v.string()),
88
+ },
89
+ returns: v.id("wakeableWork"),
90
+ handler: async (ctx, args) => {
91
+ // orgId is a caller-supplied ARGUMENT, validated here (not derived from
92
+ // ctx.auth -- component boundaries do not propagate auth, see note A
93
+ // and component/lib/auth.ts). Validated BEFORE the funding call so a
94
+ // missing orgId is refused with OUR message, not a downstream one.
95
+ const orgId = requireOrgId(args.orgId);
96
+ const result = await ctx.runMutation(internal.engine.mandates.spendInternal, {
97
+ orgId,
98
+ agentId: args.agentId,
99
+ scope: args.scope,
100
+ cost: args.cost,
101
+ reason: args.reason,
102
+ });
103
+ if (!result.ok) {
104
+ // REFUSED end-to-end: the approval request already committed inside
105
+ // `mandates.spendInternal`'s own transaction (step above); no
106
+ // further transaction runs, so the work never enters the pool.
107
+ throw new Error(`Mandate refused: agent ${args.agentId} has no remaining authority ` +
108
+ `for scope "${args.scope}" (cost ${args.cost}). Approval request ` +
109
+ `${result.approvalId} was created and is pending human review.`);
110
+ }
111
+ return await ctx.runMutation(internal.engine.wakeup.enqueueInternal, {
112
+ orgId,
113
+ workflowId: args.workflowId,
114
+ awaitedEventName: args.awaitedEventName,
115
+ workFunctionHandle: args.workFunctionHandle,
116
+ payload: args.payload,
117
+ });
118
+ },
119
+ });
120
+ // enqueueInternal -- INTERNAL ONLY variant, kept for the two-transaction
121
+ // funding protocol (`enqueue` calls this in its SECOND transaction
122
+ // once `mandates.spendInternal` returned `{ ok: true }`, same split as the
123
+ // app-level prototype). Unreachable from any client: Convex excludes
124
+ // `internal.*` functions from the API surface shipped to clients.
125
+ export const enqueueInternal = internalMutation({
126
+ args: {
127
+ orgId: v.string(),
128
+ workflowId: v.string(),
129
+ awaitedEventName: v.string(),
130
+ workFunctionHandle: v.string(),
131
+ payload: v.optional(v.string()),
132
+ },
133
+ returns: v.id("wakeableWork"),
134
+ handler: async (ctx, args) => {
135
+ const orgId = requireOrgId(args.orgId);
136
+ if (args.workflowId.length === 0) {
137
+ throw new Error("Validation: workflowId must not be empty");
138
+ }
139
+ if (args.awaitedEventName.length === 0) {
140
+ throw new Error("Validation: awaitedEventName must not be empty");
141
+ }
142
+ if (args.workFunctionHandle.length === 0) {
143
+ throw new Error("Validation: workFunctionHandle must not be empty");
144
+ }
145
+ const now = Date.now();
146
+ const wakeableWorkId = await ctx.db.insert("wakeableWork", {
147
+ orgId,
148
+ workflowId: args.workflowId,
149
+ awaitedEventName: args.awaitedEventName,
150
+ workId: "",
151
+ status: "pending",
152
+ createdAt: now,
153
+ updatedAt: now,
154
+ });
155
+ const workId = await wakeupPool.enqueueAction(ctx, args.workFunctionHandle, { orgId, payload: args.payload }, { onComplete: internal.engine.wakeup.onWorkComplete, context: { wakeableWorkId } });
156
+ await ctx.db.patch(wakeableWorkId, { workId });
157
+ return wakeableWorkId;
158
+ },
159
+ });
160
+ // Best-effort cancel -- lets a caller pull work back out of the pool before
161
+ // it starts; the wake still fires (workpool reports `kind: "canceled"` to
162
+ // `onComplete`, see below) so the awaiting workflow is never left hanging.
163
+ export const cancel = mutation({
164
+ args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
165
+ returns: v.null(),
166
+ handler: async (ctx, args) => {
167
+ const orgId = requireOrgId(args.orgId);
168
+ const row = await ctx.db.get(args.wakeableWorkId);
169
+ assertSameOrg(orgId, row, "Wakeable work not found");
170
+ if (row.status !== "pending")
171
+ return null;
172
+ await wakeupPool.cancel(ctx, row.workId);
173
+ return null;
174
+ },
175
+ });
176
+ export const getStatus = query({
177
+ args: { orgId: v.string(), wakeableWorkId: v.id("wakeableWork") },
178
+ returns: v.object({
179
+ status: v.union(v.literal("pending"), v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
180
+ resultSummary: v.optional(v.string()),
181
+ }),
182
+ handler: async (ctx, args) => {
183
+ const orgId = requireOrgId(args.orgId);
184
+ const row = await ctx.db.get(args.wakeableWorkId);
185
+ assertSameOrg(orgId, row, "Wakeable work not found");
186
+ return { status: row.status, resultSummary: row.resultSummary };
187
+ },
188
+ });
189
+ // ---------------------------------------------------------------------
190
+ // INTERNAL — the wake itself. Invoked ONLY by the workpool's own
191
+ // completion machinery (defineOnComplete), never by a client and never on
192
+ // a timer.
193
+ // ---------------------------------------------------------------------
194
+ // THE WAKE. This is the entire mechanism: onComplete -> sendEvent. It is a
195
+ // mutation the workpool component calls exactly once per work item, in its
196
+ // own transaction, at the moment the work resolves -- whichever way it
197
+ // resolves. All three `result.kind`s below reach `sendEvent`: a workflow
198
+ // asleep because its work vanished (failed/cancelled, no wake) would be
199
+ // strictly worse than one that polls.
200
+ const onWorkCompleteContext = v.object({ wakeableWorkId: v.id("wakeableWork") });
201
+ export const onWorkComplete = wakeupPool.defineOnComplete({
202
+ context: onWorkCompleteContext,
203
+ handler: async (ctx, { context, result }) => {
204
+ const row = await ctx.db.get(context.wakeableWorkId);
205
+ if (!row)
206
+ return; // work outlived its tracking row -- nothing to wake
207
+ const status = result.kind === "success"
208
+ ? "completed"
209
+ : result.kind === "failed"
210
+ ? "failed"
211
+ : "cancelled";
212
+ const resultSummary = result.kind === "failed" ? result.error : undefined;
213
+ await ctx.db.patch(row._id, {
214
+ status,
215
+ resultSummary,
216
+ updatedAt: Date.now(),
217
+ });
218
+ await workflow.sendEvent(ctx, {
219
+ workflowId: row.workflowId,
220
+ name: row.awaitedEventName,
221
+ validator: v.object({
222
+ status: v.union(v.literal("completed"), v.literal("failed"), v.literal("cancelled")),
223
+ resultSummary: v.optional(v.string()),
224
+ }),
225
+ value: { status, resultSummary },
226
+ });
227
+ },
228
+ });
@@ -0,0 +1,11 @@
1
+ import { WorkflowManager } from "@convex-dev/workflow";
2
+ export declare const resolveManifestInternal: import("convex/server").RegisteredQuery<"internal", {
3
+ sessionId: import("convex/values").GenericId<"sessions">;
4
+ }, Promise<null>>;
5
+ export declare const recordTurnStartedInternal: import("convex/server").RegisteredMutation<"internal", {
6
+ sessionId: import("convex/values").GenericId<"sessions">;
7
+ }, Promise<null>>;
8
+ export declare const workflow: WorkflowManager;
9
+ export declare const sessionWorkflowV1: import("convex/server").RegisteredMutation<"internal", import("@convex-dev/workflow").WorkflowArgs<{
10
+ sessionId: import("convex/values").VId<import("convex/values").GenericId<"sessions">, "required">;
11
+ }>, import("@convex-dev/workflow").WorkflowId>;
@@ -0,0 +1,84 @@
1
+ import { WorkflowManager } from "@convex-dev/workflow";
2
+ import { v } from "convex/values";
3
+ import { components, internal } from "../_generated/api.js";
4
+ import { internalMutation, internalQuery } from "../_generated/server.js";
5
+ // The durable, VERSIONED session workflow. This is what makes
6
+ // `sessions.start` open a session PILOTED by `@convex-dev/workflow` rather
7
+ // than a bare event table.
8
+ //
9
+ // Trust model at this boundary: the workflow engine runs with NO end-user
10
+ // identity and NO client-supplied orgId (component isolation, same
11
+ // discipline as component/engine/durableJob.ts and component/lib/auth.ts).
12
+ // Every internal step below reads orgId from the `sessions` row itself --
13
+ // never from an argument a workflow step could be handed by a caller.
14
+ // Internal (server-to-server only) variants of the resolver/session
15
+ // mutations, used by the workflow step handlers below. The workflow engine
16
+ // runs with no end-user identity, so these read/write scoped strictly by
17
+ // the orgId already recorded on the session row -- never trusting a
18
+ // client-supplied orgId.
19
+ export const resolveManifestInternal = internalQuery({
20
+ args: { sessionId: v.id("sessions") },
21
+ returns: v.null(),
22
+ handler: async (ctx, args) => {
23
+ const session = await ctx.db.get(args.sessionId);
24
+ if (!session)
25
+ throw new Error("Session not found");
26
+ const agent = await ctx.db.get(session.agentId);
27
+ if (!agent || agent.orgId !== session.orgId) {
28
+ throw new Error("Agent not found");
29
+ }
30
+ // Resolution result is consumed by the caller via the public
31
+ // engine/resolvers:resolveManifest query; this internal step exists to
32
+ // prove (and test) that the reread happens at each boundary.
33
+ return null;
34
+ },
35
+ });
36
+ export const recordTurnStartedInternal = internalMutation({
37
+ args: { sessionId: v.id("sessions") },
38
+ returns: v.null(),
39
+ handler: async (ctx, args) => {
40
+ const session = await ctx.db.get(args.sessionId);
41
+ if (!session)
42
+ throw new Error("Session not found");
43
+ await ctx.db.insert("sessionEvents", {
44
+ orgId: session.orgId,
45
+ sessionId: args.sessionId,
46
+ name: "turn.started",
47
+ payload: undefined,
48
+ createdAt: Date.now(),
49
+ });
50
+ return null;
51
+ },
52
+ });
53
+ // Versioned workflow discipline (workflow#35): "if you change much
54
+ // of anything in a workflow while there are ongoing workflows, they'll
55
+ // fail." We never edit a workflow function in place once sessions
56
+ // reference it -- a breaking change ships as `sessionWorkflowV2`, and the
57
+ // deploy is preceded by draining v1 sessions (0 rows with
58
+ // sessions.workflowVersion == "v1" and status == "running"). Same
59
+ // discipline as `engine/durableJob.ts:durableJobWorkflowV1`.
60
+ export const workflow = new WorkflowManager(components.workflow);
61
+ // sessionWorkflowV1 -- the durable session loop. At `session.started` and
62
+ // at every `turn.started` boundary it rereads the agent aggregate (never
63
+ // cached, never compiled) via `resolveManifestInternal`. Tool approvals
64
+ // pause the workflow via `step.awaitEvent`, guarded by the self-built
65
+ // timeout (workflow#177) once that surface is ported into this component.
66
+ export const sessionWorkflowV1 = workflow.define({
67
+ args: { sessionId: v.id("sessions") },
68
+ returns: v.null(),
69
+ handler: async (step, args) => {
70
+ // session.started boundary: read the aggregate once at start.
71
+ await step.runQuery(internal.engine.workflows.resolveManifestInternal, {
72
+ sessionId: args.sessionId,
73
+ });
74
+ // turn.started boundary: re-read on every turn so an agent/skill edit
75
+ // made mid-session is visible at the very next turn, with zero deploy.
76
+ await step.runMutation(internal.engine.workflows.recordTurnStartedInternal, {
77
+ sessionId: args.sessionId,
78
+ });
79
+ await step.runQuery(internal.engine.workflows.resolveManifestInternal, {
80
+ sessionId: args.sessionId,
81
+ });
82
+ return null;
83
+ },
84
+ });