@electric-ax/agents-server 0.4.16 → 0.4.18

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.
@@ -1,6 +1,7 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import fastq from 'fastq'
3
3
  import {
4
+ COMPOSER_INPUT_MESSAGE_TYPE,
4
5
  assertTags,
5
6
  entityStateSchema,
6
7
  getCronStreamPath,
@@ -11,6 +12,8 @@ import {
11
12
  manifestSharedStateKey,
12
13
  manifestSourceKey,
13
14
  resolveCronScheduleSpec,
15
+ validateComposerInputPayload,
16
+ validateSlashCommandDefinitions,
14
17
  } from '@electric-ax/agents-runtime'
15
18
  import type { EventPointer } from '@electric-ax/agents-runtime'
16
19
  import {
@@ -172,6 +175,38 @@ type ForkSubtreeOptions = {
172
175
  * `main` stream; shared-state streams clone at HEAD regardless.
173
176
  */
174
177
  forkPointer?: EventPointer
178
+ /**
179
+ * Named server-resolved anchor. Resolves to a concrete `forkPointer`
180
+ * by reading the source root's `main` history. Mutually exclusive with
181
+ * `forkPointer`. Use this when the caller doesn't have access to the
182
+ * source's per-row pointer side-table (e.g. an agent forking a session
183
+ * via a tool).
184
+ *
185
+ * - `latest_completed_run` — the most recent `runs` row on the source
186
+ * with `status === 'completed'`. Errors if no such row exists. This
187
+ * is the eligibility rule the per-row "Fork from here" UI uses.
188
+ */
189
+ anchor?: `latest_completed_run`
190
+ /**
191
+ * Optional parent URL for the new root fork entity. When set, the
192
+ * new fork is a CHILD of this URL — its `parent` field is overridden
193
+ * (rather than inherited from the source, which is `null` for the
194
+ * only allowed source: a top-level entity). Pairs with `wake` to give
195
+ * the parent reply-delivery the same way spawn does.
196
+ */
197
+ parent?: string
198
+ /**
199
+ * Optional wake subscription registered on the new root fork at fork
200
+ * time. Same shape and semantics as `spawn`'s `wake` (see
201
+ * `TypedSpawnRequest.wake`). Typically set by the agent `fork` tool
202
+ * to wire reply delivery via the parent's manifest-anchored wake.
203
+ */
204
+ wake?: TypedSpawnRequest[`wake`]
205
+ /**
206
+ * Optional tags stamped onto the new root fork entity in addition
207
+ * to those copied from the source. Mirrors `spawn`'s `tags`.
208
+ */
209
+ tags?: Record<string, string>
175
210
  }
176
211
 
177
212
  type ForkEntityPlan = {
@@ -430,6 +465,7 @@ export class EntityManager {
430
465
  this.validateSchema(req.creation_schema)
431
466
  this.validateSchemaMap(req.inbox_schemas)
432
467
  this.validateSchemaMap(req.state_schemas)
468
+ this.validateSlashCommands(req.slash_commands)
433
469
  const defaultDispatchPolicy = req.default_dispatch_policy
434
470
  ? this.validateDispatchPolicy(req.default_dispatch_policy, {
435
471
  label: `default_dispatch_policy`,
@@ -444,6 +480,7 @@ export class EntityManager {
444
480
  creation_schema: req.creation_schema,
445
481
  inbox_schemas: req.inbox_schemas,
446
482
  state_schemas: req.state_schemas,
483
+ slash_commands: req.slash_commands,
447
484
  serve_endpoint: req.serve_endpoint,
448
485
  default_dispatch_policy: defaultDispatchPolicy,
449
486
  revision: existing ? existing.revision + 1 : 1,
@@ -735,6 +772,19 @@ export class EntityManager {
735
772
  createdEvent as Record<string, unknown>,
736
773
  ]
737
774
 
775
+ const slashCommandTimestamp = new Date().toISOString()
776
+ for (const command of entityType.slash_commands ?? []) {
777
+ const slashCommandEvent = entityStateSchema.slashCommands.insert({
778
+ key: command.name,
779
+ value: {
780
+ ...command,
781
+ source: `static`,
782
+ updated_at: slashCommandTimestamp,
783
+ },
784
+ } as any)
785
+ initialEvents.push(slashCommandEvent as Record<string, unknown>)
786
+ }
787
+
738
788
  if (req.initialMessage !== undefined) {
739
789
  const msgNow = new Date().toISOString()
740
790
  const inboxEvent = entityStateSchema.inbox.insert({
@@ -742,6 +792,7 @@ export class EntityManager {
742
792
  value: {
743
793
  from: req.created_by ?? req.parent ?? `spawn`,
744
794
  payload: req.initialMessage,
795
+ message_type: req.initialMessageType,
745
796
  timestamp: msgNow,
746
797
  },
747
798
  } as any)
@@ -890,6 +941,13 @@ export class EntityManager {
890
941
  const writeEntityLocks = new Set<string>()
891
942
  const writeStreamLocks = new Set<string>()
892
943
 
944
+ // Anchor-forks resolve to a concrete pointer server-side by reading the
945
+ // source's `main` history. Below this point the resolved pointer (or
946
+ // an explicit one from `opts.forkPointer`) drives the same code path —
947
+ // anchor handling collapses entirely into the pointer-fork flow.
948
+ const usePointerPath =
949
+ opts.forkPointer !== undefined || opts.anchor !== undefined
950
+
893
951
  try {
894
952
  // For pointer-forks we read the source root HISTORICALLY at a
895
953
  // frozen offset, so concurrent activity on the root past the
@@ -901,7 +959,7 @@ export class EntityManager {
901
959
  // those are HEAD-cloned and need a stable snapshot. For HEAD-forks
902
960
  // the old all-idle requirement still applies.
903
961
  let sourceTree: Array<ElectricAgentsEntity>
904
- if (opts.forkPointer) {
962
+ if (usePointerPath) {
905
963
  const rootEntity = await this.registry.getEntity(rootUrl)
906
964
  if (!rootEntity) {
907
965
  throw new ElectricAgentsError(
@@ -930,11 +988,11 @@ export class EntityManager {
930
988
  )
931
989
  }
932
990
 
933
- // When forking at a pointer, pre-read the root's main, validate the
934
- // pointer against the source's true history, and materialise the
935
- // root-at-pointer snapshot fragments. The pointer only applies to
936
- // the root's `main` stream. Descendants kept by the manifest filter
937
- // are forked at HEAD.
991
+ // When forking at a pointer (or anchor), pre-read the root's main,
992
+ // validate the pointer against the source's true history, and
993
+ // materialise the root-at-pointer snapshot fragments. The pointer
994
+ // only applies to the root's `main` stream. Descendants kept by
995
+ // the manifest filter are forked at HEAD.
938
996
  //
939
997
  // Pointer→position translation: the runtime mints pointers as
940
998
  // `{ offset: previousBatchOffset, subOffset: itemIndex+1 }`, where
@@ -953,16 +1011,32 @@ export class EntityManager {
953
1011
  sharedStateIds: Set<string>
954
1012
  }
955
1013
  | undefined
956
- if (opts.forkPointer) {
1014
+ let effectiveForkPointer: EventPointer | undefined = opts.forkPointer
1015
+ if (usePointerPath) {
957
1016
  const sourceEvents = await this.streamClient.readJson<
958
1017
  Record<string, unknown>
959
1018
  >(sourceRoot.streams.main)
960
1019
  const flat = sourceEvents.flatMap((item) =>
961
1020
  Array.isArray(item) ? item : [item]
962
1021
  ) as Array<Record<string, unknown>>
1022
+ if (!effectiveForkPointer && opts.anchor === `latest_completed_run`) {
1023
+ effectiveForkPointer = this.resolveLatestCompletedRunPointer(
1024
+ flat,
1025
+ sourceRoot.streams.main
1026
+ )
1027
+ }
1028
+ if (!effectiveForkPointer) {
1029
+ // Defensive — would only fire on a future anchor variant we
1030
+ // forget to handle above.
1031
+ throw new ElectricAgentsError(
1032
+ ErrCodeInvalidRequest,
1033
+ `Internal: pointer-path fork with no resolved pointer`,
1034
+ 500
1035
+ )
1036
+ }
963
1037
  const target = this.resolveForkPointerTarget(
964
1038
  flat,
965
- opts.forkPointer,
1039
+ effectiveForkPointer,
966
1040
  sourceRoot.streams.main
967
1041
  )
968
1042
  const filteredEvents = flat.slice(0, target)
@@ -994,7 +1068,7 @@ export class EntityManager {
994
1068
  // subtree except the root) are HEAD-cloned, so they must be idle
995
1069
  // before we read their snapshots. Wait+lock only those — the root
996
1070
  // was skipped above.
997
- if (opts.forkPointer) {
1071
+ if (usePointerPath) {
998
1072
  const descendants = effectiveSubtree.filter(
999
1073
  (entity) => entity.url !== sourceRoot.url
1000
1074
  )
@@ -1047,6 +1121,28 @@ export class EntityManager {
1047
1121
  opts.createdBy
1048
1122
  )
1049
1123
 
1124
+ // Override fields on the new root fork's plan from caller opts.
1125
+ // Plumbed through from `forkBodySchema` — descendants in
1126
+ // `effectiveSubtree` keep what `buildForkEntityPlans` copied.
1127
+ //
1128
+ // - `parent`: child-fork relationship (vs the default inheritance
1129
+ // from the source, which is `null` for a top-level source).
1130
+ // - `tags`: merged on top of source tags (caller-supplied wins).
1131
+ const rootPlanForOverrides = entityPlans.find(
1132
+ (plan) => plan.source.url === rootUrl
1133
+ )
1134
+ if (rootPlanForOverrides) {
1135
+ if (opts.parent !== undefined) {
1136
+ rootPlanForOverrides.fork.parent = opts.parent
1137
+ }
1138
+ if (opts.tags !== undefined) {
1139
+ rootPlanForOverrides.fork.tags = {
1140
+ ...(rootPlanForOverrides.fork.tags ?? {}),
1141
+ ...opts.tags,
1142
+ }
1143
+ }
1144
+ }
1145
+
1050
1146
  this.addForkLocks(
1051
1147
  this.forkWriteLockedEntities,
1052
1148
  effectiveSubtree.map((entity) => entity.url),
@@ -1071,8 +1167,8 @@ export class EntityManager {
1071
1167
  await this.streamClient.fork(
1072
1168
  plan.fork.streams.main,
1073
1169
  plan.source.streams.main,
1074
- isRoot && opts.forkPointer
1075
- ? { forkPointer: opts.forkPointer }
1170
+ isRoot && effectiveForkPointer
1171
+ ? { forkPointer: effectiveForkPointer }
1076
1172
  : undefined
1077
1173
  )
1078
1174
  createdStreams.push(plan.fork.streams.main)
@@ -1127,6 +1223,30 @@ export class EntityManager {
1127
1223
  createdEntities.push(plan.fork.url)
1128
1224
  }
1129
1225
 
1226
+ // Register a wake subscription on the new root fork when the
1227
+ // caller asked for one. Mirrors the spawn flow's wake handling
1228
+ // (entity-manager.spawnInner). Rollback below already calls
1229
+ // `wakeRegistry.unregisterBySource(forkUrl)` for every created
1230
+ // entity, so this cleans up automatically on failure.
1231
+ if (opts.wake !== undefined) {
1232
+ const rootPlan = entityPlans.find(
1233
+ (plan) => plan.source.url === rootUrl
1234
+ )
1235
+ if (rootPlan) {
1236
+ await this.wakeRegistry.register({
1237
+ tenantId: this.tenantId,
1238
+ subscriberUrl: opts.wake.subscriberUrl,
1239
+ sourceUrl: rootPlan.fork.url,
1240
+ condition: opts.wake.condition,
1241
+ debounceMs: opts.wake.debounceMs,
1242
+ timeoutMs: opts.wake.timeoutMs,
1243
+ oneShot: false,
1244
+ includeResponse: opts.wake.includeResponse,
1245
+ manifestKey: opts.wake.manifestKey,
1246
+ })
1247
+ }
1248
+ }
1249
+
1130
1250
  for (const plan of entityPlans) {
1131
1251
  const manifests =
1132
1252
  activeManifestsByEntity.get(plan.fork.url) ?? new Map()
@@ -1442,6 +1562,58 @@ export class EntityManager {
1442
1562
  return positionAtAnchor + pointer.subOffset
1443
1563
  }
1444
1564
 
1565
+ /**
1566
+ * Find an `EventPointer` that addresses the most recent `runs` row on
1567
+ * the source's `main` stream with `status === 'completed'`. Mirrors the
1568
+ * eligibility rule the per-row "Fork from here" UI applies — only
1569
+ * completed runs are valid fork anchors; `started`/`failed` rows are
1570
+ * skipped.
1571
+ *
1572
+ * The pointer is computed in the same coordinate system the runtime
1573
+ * mints pointers in (see entity-stream-db's onBeforeBatch): for a row
1574
+ * R in log entry E, the pointer is `{ offset: P, subOffset: K }` where
1575
+ * `P` is the END offset of the log entry preceding E (or `null` for
1576
+ * stream start) and `K` is R's 1-indexed position within E.
1577
+ */
1578
+ private resolveLatestCompletedRunPointer(
1579
+ events: ReadonlyArray<Record<string, unknown>>,
1580
+ streamPath: string
1581
+ ): EventPointer {
1582
+ let priorEntryOffset: string | null = null
1583
+ let currentEntryOffset: string | null = null
1584
+ let positionInEntry = 0
1585
+ let latest: EventPointer | null = null
1586
+
1587
+ for (const event of events) {
1588
+ const headers = isRecord(event.headers) ? event.headers : undefined
1589
+ const eventOffset =
1590
+ typeof headers?.offset === `string` ? headers.offset : null
1591
+
1592
+ if (eventOffset !== currentEntryOffset) {
1593
+ priorEntryOffset = currentEntryOffset
1594
+ currentEntryOffset = eventOffset
1595
+ positionInEntry = 0
1596
+ }
1597
+ positionInEntry++
1598
+
1599
+ if (event.type !== `run`) continue
1600
+ if (headers?.operation === `delete`) continue
1601
+ if (!isRecord(event.value)) continue
1602
+ if (event.value.status !== `completed`) continue
1603
+
1604
+ latest = { offset: priorEntryOffset, subOffset: positionInEntry }
1605
+ }
1606
+
1607
+ if (!latest) {
1608
+ throw new ElectricAgentsError(
1609
+ ErrCodeInvalidRequest,
1610
+ `Source ${streamPath} has no completed run to fork from`,
1611
+ 400
1612
+ )
1613
+ }
1614
+ return latest
1615
+ }
1616
+
1445
1617
  /**
1446
1618
  * Compute the subset of `sourceTree` that survives the manifest filter
1447
1619
  * applied at the root. After filtering the root's manifest at the fork
@@ -3512,7 +3684,9 @@ export class EntityManager {
3512
3684
  creation_schema: existing.creation_schema,
3513
3685
  inbox_schemas: mergedInbox,
3514
3686
  state_schemas: mergedState,
3687
+ slash_commands: existing.slash_commands,
3515
3688
  serve_endpoint: existing.serve_endpoint,
3689
+ default_dispatch_policy: existing.default_dispatch_policy,
3516
3690
  revision: nextRevision,
3517
3691
  created_at: existing.created_at,
3518
3692
  updated_at: now,
@@ -3601,6 +3775,20 @@ export class EntityManager {
3601
3775
  }
3602
3776
  }
3603
3777
 
3778
+ private validateSlashCommands(input: unknown): void {
3779
+ const validationError = validateSlashCommandDefinitions(input)
3780
+ if (!validationError) {
3781
+ return
3782
+ }
3783
+
3784
+ throw new ElectricAgentsError(
3785
+ ErrCodeSchemaValidationFailed,
3786
+ validationError.message,
3787
+ 422,
3788
+ validationError.details
3789
+ )
3790
+ }
3791
+
3604
3792
  private async validateSendRequest(
3605
3793
  entityUrl: string,
3606
3794
  req: SendRequest
@@ -3617,7 +3805,17 @@ export class EntityManager {
3617
3805
  )
3618
3806
  }
3619
3807
 
3620
- if (req.type && entity.type) {
3808
+ if (req.type === COMPOSER_INPUT_MESSAGE_TYPE) {
3809
+ const valErr = validateComposerInputPayload(req.payload)
3810
+ if (valErr) {
3811
+ throw new ElectricAgentsError(
3812
+ ErrCodeSchemaValidationFailed,
3813
+ valErr.message,
3814
+ 422,
3815
+ valErr.details
3816
+ )
3817
+ }
3818
+ } else if (req.type && entity.type) {
3621
3819
  const { inboxSchemas } = await this.getEffectiveSchemas(entity)
3622
3820
  if (inboxSchemas) {
3623
3821
  const schema = inboxSchemas[req.type]
@@ -633,6 +633,7 @@ export class PostgresRegistry {
633
633
  creationSchema: et.creation_schema ?? null,
634
634
  inboxSchemas: et.inbox_schemas ?? null,
635
635
  stateSchemas: et.state_schemas ?? null,
636
+ slashCommands: et.slash_commands ?? null,
636
637
  serveEndpoint: et.serve_endpoint ?? null,
637
638
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
638
639
  revision: et.revision,
@@ -646,6 +647,7 @@ export class PostgresRegistry {
646
647
  creationSchema: et.creation_schema ?? null,
647
648
  inboxSchemas: et.inbox_schemas ?? null,
648
649
  stateSchemas: et.state_schemas ?? null,
650
+ slashCommands: et.slash_commands ?? null,
649
651
  serveEndpoint: et.serve_endpoint ?? null,
650
652
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
651
653
  revision: et.revision,
@@ -668,6 +670,7 @@ export class PostgresRegistry {
668
670
  creationSchema: et.creation_schema ?? null,
669
671
  inboxSchemas: et.inbox_schemas ?? null,
670
672
  stateSchemas: et.state_schemas ?? null,
673
+ slashCommands: et.slash_commands ?? null,
671
674
  serveEndpoint: et.serve_endpoint ?? null,
672
675
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
673
676
  revision: et.revision,
@@ -709,6 +712,7 @@ export class PostgresRegistry {
709
712
  creationSchema: et.creation_schema ?? null,
710
713
  inboxSchemas: et.inbox_schemas ?? null,
711
714
  stateSchemas: et.state_schemas ?? null,
715
+ slashCommands: et.slash_commands ?? null,
712
716
  serveEndpoint: et.serve_endpoint ?? null,
713
717
  defaultDispatchPolicy: et.default_dispatch_policy ?? null,
714
718
  revision: et.revision,
@@ -1829,6 +1833,9 @@ export class PostgresRegistry {
1829
1833
  state_schemas: row.stateSchemas as
1830
1834
  | Record<string, Record<string, unknown>>
1831
1835
  | undefined,
1836
+ slash_commands:
1837
+ (row.slashCommands as ElectricAgentsEntityType[`slash_commands`]) ??
1838
+ undefined,
1832
1839
  serve_endpoint: row.serveEndpoint ?? undefined,
1833
1840
  default_dispatch_policy:
1834
1841
  (row.defaultDispatchPolicy as ElectricAgentsEntityType[`default_dispatch_policy`]) ??
@@ -135,6 +135,7 @@ const spawnBodySchema = Type.Object({
135
135
  sandbox: Type.Optional(sandboxChoiceSchema),
136
136
  initialMessage: Type.Optional(Type.Unknown()),
137
137
  grants: Type.Optional(Type.Array(entityPermissionGrantInputSchema)),
138
+ initialMessageType: Type.Optional(Type.String()),
138
139
  wake: Type.Optional(
139
140
  Type.Object({
140
141
  subscriberUrl: Type.String(),
@@ -186,6 +187,19 @@ function agentUrlPath(value: string): string {
186
187
  }
187
188
  }
188
189
 
190
+ async function hasValidAgentWriteToken(
191
+ request: AgentsRouteRequest,
192
+ ctx: TenantContext,
193
+ fromAgent: string
194
+ ): Promise<boolean> {
195
+ const agentUrl = agentUrlPath(fromAgent)
196
+ const token = writeTokenFromRequest(request)
197
+ if (!token) return false
198
+ const agentEntity = await ctx.entityManager.registry.getEntity(agentUrl)
199
+ if (!agentEntity) return false
200
+ return ctx.entityManager.isValidWriteToken(agentEntity, token)
201
+ }
202
+
189
203
  const inboxMessageBodySchema = Type.Object({
190
204
  payload: Type.Optional(Type.Unknown()),
191
205
  position: Type.Optional(Type.String()),
@@ -218,6 +232,41 @@ const forkBodySchema = Type.Object({
218
232
  sub_offset: Type.Number(),
219
233
  })
220
234
  ),
235
+ // Named server-resolved anchor. Resolves to a concrete fork pointer on
236
+ // the source root's `main` server-side, so callers don't need access
237
+ // to the source's per-row pointer side-table. Mutually exclusive with
238
+ // `fork_pointer`.
239
+ anchor: Type.Optional(Type.Literal(`latest_completed_run`)),
240
+ // Optional parent URL. When set, the new root fork is a CHILD of this
241
+ // URL (rather than inheriting the source's parent, which is `null`
242
+ // for the only allowed source — a top-level entity). Used by the
243
+ // agent `fork` tool to make a forking entity own its forks the same
244
+ // way a spawning entity owns its workers.
245
+ parent: Type.Optional(Type.String()),
246
+ // Optional wake subscription registered on the new root fork at fork
247
+ // time. Mirrors the `wake` field on spawn — the subscriber URL gets
248
+ // woken when the fork meets the named condition, with the response
249
+ // optionally inlined. Used to wire fork-as-child reply delivery
250
+ // through the same manifest-anchored mechanism spawn uses.
251
+ wake: Type.Optional(
252
+ Type.Object({
253
+ subscriberUrl: Type.String(),
254
+ condition: wakeConditionSchema,
255
+ debounceMs: Type.Optional(Type.Number()),
256
+ timeoutMs: Type.Optional(Type.Number()),
257
+ includeResponse: Type.Optional(Type.Boolean()),
258
+ manifestKey: Type.Optional(Type.String()),
259
+ })
260
+ ),
261
+ // Optional initial inbox message for the new root fork. Folds
262
+ // fork+send into one round-trip for the caller. Not atomic with
263
+ // fork creation: the server sends it after the fork is created
264
+ // and dispatch-linked (see the `entityManager.send` call below),
265
+ // so a failed send can leave an idle dispatched fork.
266
+ initialMessage: Type.Optional(Type.Unknown()),
267
+ // Optional tags stamped on the new root fork entity in addition to
268
+ // those copied from the source. Mirrors spawn's `tags`.
269
+ tags: Type.Optional(stringRecordSchema),
221
270
  })
222
271
 
223
272
  const setTagBodySchema = Type.Object({
@@ -1087,8 +1136,57 @@ async function forkEntity(
1087
1136
  if (principalMutationError) return principalMutationError
1088
1137
 
1089
1138
  const parsed = routeBody<ForkBody>(request)
1139
+ if (parsed.fork_pointer && parsed.anchor) {
1140
+ return apiError(
1141
+ 400,
1142
+ ErrCodeInvalidRequest,
1143
+ `fork_pointer and anchor are mutually exclusive`
1144
+ )
1145
+ }
1090
1146
  const { entityUrl, entity } = requireExistingEntityRoute(request)
1091
1147
  await assertDispatchPolicyAllowed(ctx, entity.dispatch_policy)
1148
+
1149
+ // Validate `parent` and `wake.subscriberUrl` before forking — mirrors
1150
+ // the spawn route's parent-validation flow. Without these checks, a
1151
+ // direct HTTP caller could attach a fork under an arbitrary parent or
1152
+ // register a wake firing to an arbitrary subscriber.
1153
+ if (parsed.parent !== undefined) {
1154
+ const parent = await ctx.entityManager.registry.getEntity(parsed.parent)
1155
+ if (!parent) {
1156
+ return apiError(404, ErrCodeNotFound, `Parent entity not found`)
1157
+ }
1158
+ if (!(await canAccessEntity(ctx, parent, `spawn`, request as Request))) {
1159
+ return apiError(
1160
+ 401,
1161
+ ErrCodeUnauthorized,
1162
+ `Principal is not allowed to spawn children from ${parent.url}`
1163
+ )
1164
+ }
1165
+ }
1166
+ if (parsed.wake !== undefined) {
1167
+ // The only sensible target for a fork's wake is the new fork's
1168
+ // parent (so the parent gets woken when the fork's run finishes
1169
+ // — same model as spawn). Require parent + matching subscriber to
1170
+ // prevent a caller from registering a wake firing to an entity
1171
+ // they don't own. If subscriber-flexibility ever becomes a real
1172
+ // use case, replace this with a proper canAccessEntity check on
1173
+ // the subscriber.
1174
+ if (parsed.parent === undefined) {
1175
+ return apiError(
1176
+ 400,
1177
+ ErrCodeInvalidRequest,
1178
+ `wake requires parent (the fork's wake fires to its parent)`
1179
+ )
1180
+ }
1181
+ if (parsed.wake.subscriberUrl !== parsed.parent) {
1182
+ return apiError(
1183
+ 401,
1184
+ ErrCodeUnauthorized,
1185
+ `wake.subscriberUrl must match parent`
1186
+ )
1187
+ }
1188
+ }
1189
+
1092
1190
  const result = await ctx.entityManager.forkSubtree(entityUrl, {
1093
1191
  rootInstanceId: parsed.instance_id,
1094
1192
  waitTimeoutMs: parsed.waitTimeoutMs,
@@ -1099,10 +1197,24 @@ async function forkEntity(
1099
1197
  subOffset: parsed.fork_pointer.sub_offset,
1100
1198
  },
1101
1199
  }),
1200
+ ...(parsed.anchor && { anchor: parsed.anchor }),
1201
+ ...(parsed.parent !== undefined && { parent: parsed.parent }),
1202
+ ...(parsed.wake !== undefined && { wake: parsed.wake }),
1203
+ ...(parsed.tags !== undefined && { tags: parsed.tags }),
1102
1204
  })
1103
1205
  for (const forkedEntity of result.entities) {
1104
1206
  await linkEntityDispatchSubscription(ctx, forkedEntity)
1105
1207
  }
1208
+ // Deliver the initial message via entityManager.send AFTER the
1209
+ // dispatch subscription is linked — same ordering spawn uses. Sending
1210
+ // before linking would land the inbox row on the stream before the
1211
+ // dispatcher is subscribed, and the dispatcher would never pick it up.
1212
+ if (parsed.initialMessage !== undefined) {
1213
+ await ctx.entityManager.send(result.root.url, {
1214
+ from: parsed.parent ?? ctx.principal.url,
1215
+ payload: parsed.initialMessage,
1216
+ })
1217
+ }
1106
1218
  return json(
1107
1219
  {
1108
1220
  root: toPublicEntity(result.root),
@@ -1137,7 +1249,14 @@ async function sendEntity(
1137
1249
  }
1138
1250
  if (parsed.from_agent !== undefined) {
1139
1251
  const principalAgentUrl = agentUrlForPrincipal(principal)
1140
- if (agentUrlPath(parsed.from_agent) !== principalAgentUrl) {
1252
+ const fromAgentUrl = agentUrlPath(parsed.from_agent)
1253
+ const matchesPrincipalAgent = fromAgentUrl === principalAgentUrl
1254
+ const hasAgentWriteToken = await hasValidAgentWriteToken(
1255
+ request,
1256
+ ctx,
1257
+ parsed.from_agent
1258
+ )
1259
+ if (!matchesPrincipalAgent && !hasAgentWriteToken) {
1141
1260
  return apiError(
1142
1261
  400,
1143
1262
  ErrCodeInvalidRequest,
@@ -1293,6 +1412,7 @@ async function spawnEntity(
1293
1412
  dispatch_policy: dispatchPolicy,
1294
1413
  sandbox: parsed.sandbox,
1295
1414
  initialMessage: undefined,
1415
+ initialMessageType: undefined,
1296
1416
  wake: parsed.wake,
1297
1417
  created_by: principal.url,
1298
1418
  })
@@ -1325,6 +1445,7 @@ async function spawnEntity(
1325
1445
  await ctx.entityManager.send(entity.url, {
1326
1446
  from: principal.url,
1327
1447
  payload: parsed.initialMessage,
1448
+ type: parsed.initialMessageType,
1328
1449
  })
1329
1450
  }
1330
1451
  if (!linkBeforeInitialMessage) {
@@ -45,6 +45,27 @@ type PublicEntityTypeResponse = ElectricAgentsEntityType & {
45
45
 
46
46
  const jsonObjectSchema = Type.Record(Type.String(), Type.Unknown())
47
47
  const schemaMapSchema = Type.Record(Type.String(), jsonObjectSchema)
48
+ const slashCommandArgumentSchema = Type.Object(
49
+ {
50
+ name: Type.String(),
51
+ type: Type.Union([
52
+ Type.Literal(`string`),
53
+ Type.Literal(`number`),
54
+ Type.Literal(`boolean`),
55
+ ]),
56
+ required: Type.Optional(Type.Boolean()),
57
+ description: Type.Optional(Type.String()),
58
+ },
59
+ { additionalProperties: false }
60
+ )
61
+ const slashCommandSchema = Type.Object(
62
+ {
63
+ name: Type.String(),
64
+ description: Type.Optional(Type.String()),
65
+ arguments: Type.Optional(Type.Array(slashCommandArgumentSchema)),
66
+ },
67
+ { additionalProperties: false }
68
+ )
48
69
 
49
70
  const typePermissionGrantInputSchema = Type.Object(
50
71
  {
@@ -66,6 +87,7 @@ const registerEntityTypeBodySchema = Type.Object(
66
87
  creation_schema: Type.Optional(jsonObjectSchema),
67
88
  inbox_schemas: Type.Optional(schemaMapSchema),
68
89
  state_schemas: Type.Optional(schemaMapSchema),
90
+ slash_commands: Type.Optional(Type.Array(slashCommandSchema)),
69
91
  serve_endpoint: Type.Optional(Type.String()),
70
92
  default_dispatch_policy: Type.Optional(dispatchPolicySchema),
71
93
  permission_grants: Type.Optional(
@@ -433,6 +455,7 @@ function normalizeEntityTypeRequest(
433
455
  creation_schema: parsed.creation_schema,
434
456
  inbox_schemas: parsed.inbox_schemas,
435
457
  state_schemas: parsed.state_schemas,
458
+ slash_commands: parsed.slash_commands,
436
459
  serve_endpoint: serveEndpoint,
437
460
  default_dispatch_policy:
438
461
  parsed.default_dispatch_policy ??
@@ -135,7 +135,7 @@ export function buildElectricProxyTarget(options: {
135
135
  } else if (table === `entity_types`) {
136
136
  target.searchParams.set(
137
137
  `columns`,
138
- `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
138
+ `"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","slash_commands","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
139
139
  )
140
140
  applyShapeWhere(
141
141
  target,