Skip to content

Commit 8bc630a

Browse files
balegasclaude
andauthored
feat(agents): generic externally-writable custom collections (comments as first consumer) (#4551)
## Summary An alternative to #4529 that delivers the same session-comments feature, but built on a **generic, extensible interface** instead of hardcoding `comments` into the entity schema. Comments becomes one *custom collection* among potentially many; the mechanism underneath is reusable. Three capabilities the agent runtime didn't have before: 1. **Opt-in externally-writable collections.** Entity state is agent-owned. A collection becomes writable from the HTTP router only when its definition sets `externallyWritable` — everything else stays agent-only by default. (Every collection is always writable *by the agent*; only an opt-in subset is writable *externally*, hence the name.) 2. **Authenticated, auditable writes.** Router writes require authentication. The server stamps the authenticated principal into the **change-event header** (provenance, outside the user payload), and the client materializes it into a read-only **virtual column** (`_principal`). A client can't spoof it via `value`. 3. **Schema-validated writes.** Each collection carries a schema; router writes validate `value` server-side via the existing `validateWriteEvent` before append. ## Design **Runtime** (`@electric-ax/agents-runtime`) - `CollectionDefinition.externallyWritable?: boolean`. - At registration, writable collections are emitted as `externally_writable_collections` (`{ type }` per collection). - The entity stream DB materializes `headers.principal` into the fixed `_principal` virtual column, mirroring `_timeline_order`, and strips it (with `_seq`/`_timeline_order`) before client write-back. - `createEntityTimelineQuery` accepts `customSources` — consumer-provided sources unioned into the timeline query. The runtime itself knows nothing about specific custom collections, and the agent's LLM context never includes them. **Server** (`@electric-ax/agents-server`) - `externally_writable_collections` persisted as a jsonb column on `entity_types` (migration 0016) and resolved via `getEffectiveSchemas`. Legacy `principalColumn` in registration payloads is accepted and ignored for version-skew tolerance. - `EntityManager.writeCollection` gates on it (403 if not writable), enforces entity-status/fork-lock, stamps `headers.principal = { url, kind, id }`, validates `value`, and appends. - `POST /:type/:instanceId/collections/:collection` exposes it (auth + `write` permission, same middleware chain as `send`). Single POST, `operation` in the body, 201 insert / 200 update·delete. **Comments consumer** (`@electric-ax/agents` + `@electric-ax/agents-server-ui`) - `commentsCollection` (schema ported from #4529, minus `from_principal`) declared as `state` on Horton and worker. - The UI projects comments into the timeline by passing a comments `customSources` entry to the timeline query (author resolved from `_principal`); comments are a UI-level concern, hardcoded only in `agents-server-ui`. - The #4529 comments UI is cloned and re-sourced: writes via an optimistic TanStack action backed by the authenticated `/collections/comments` endpoint; reads `comment.from`. ## Test Plan - [x] `@electric-ax/agents-runtime` — typecheck + 839 tests (incl. principal virtual-column materialization, write-back stripping, registration emit, timeline projection) - [x] `@electric-ax/agents-server` — typecheck + suite (incl. `writeCollection` 403/409 gating, principal-header stamping, schema validation, `externally_writable_collections` jsonb round-trip) - [x] `@electric-ax/agents` — typecheck + 60 tests (comments declared on Horton/worker) - [x] `@electric-ax/agents-server-ui` — typecheck + 88 tests (optimistic comment write to `/collections/comments`, author from `_principal`) - [ ] Manual: post a comment from the UI, confirm it syncs, right-aligns for the author, and the principal is recorded in the change-event header 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8b1d39f commit 8bc630a

55 files changed

Lines changed: 3647 additions & 339 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@electric-ax/agents-runtime': patch
3+
'@electric-ax/agents-server': patch
4+
'@electric-ax/agents-server-ui': patch
5+
'@electric-ax/agents': patch
6+
---
7+
8+
Add generic externally-writable custom collections for agent entity state: collections opt in via `externallyWritable`, writes go through an authenticated schema-validated endpoint that stamps the principal into a read-only `_principal` column, and `createEntityTimelineQuery` can project them into the timeline via `customSources`. Comments are reimplemented as one such collection, gated per agent type through a reserved `comments/v1` contract that the UI keys its comment affordances on. External writes are restricted to a per-collection operations allowlist (insert-only by default), and comments are insert-only.

packages/agents-runtime/src/client.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export {
1010
getEntityState,
1111
normalizeEntityTimelineData,
1212
normalizeTimelineEntities,
13+
TIMELINE_ORDER_FALLBACK,
1314
} from './entity-timeline'
1415
export {
1516
canonicalPgSyncOptions,
@@ -90,6 +91,7 @@ export type {
9091
export type {
9192
EntityTimelineContentItem,
9293
EntityTimelineData,
94+
EntityTimelineCustomSource,
9395
EntityTimelineInboxMode,
9496
EntityTimelineQueryOptions,
9597
EntityTimelineQueryRow,
@@ -104,4 +106,9 @@ export type {
104106
IncludesInboxMessage,
105107
IncludesRun,
106108
} from './entity-timeline'
109+
export { COMMENTS_CONTRACT, commentsCollection } from './comments-collection'
110+
export type {
111+
CommentSnapshotValue as CommentSnapshot,
112+
CommentTargetValue as CommentTarget,
113+
} from './comments-collection'
107114
export type { EntityTimelineEntry } from './use-chat'
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { z } from 'zod'
2+
import type { CollectionDefinition } from './types'
3+
4+
export type CommentTargetValue =
5+
| { kind: `comment`; key: string }
6+
| {
7+
kind: `timeline`
8+
collection:
9+
| `inbox`
10+
| `run`
11+
| `text`
12+
| `tool_call`
13+
| `wake`
14+
| `signal`
15+
| `manifest`
16+
key: string
17+
run_id?: string
18+
}
19+
20+
export type CommentSnapshotValue = {
21+
label: string
22+
text?: string
23+
from?: string
24+
timestamp?: string
25+
collection?: string
26+
}
27+
28+
export type CommentValue = {
29+
key?: string
30+
body: string
31+
timestamp: string
32+
reply_to?: CommentTargetValue
33+
target_snapshot?: CommentSnapshotValue
34+
}
35+
36+
const commentTargetSchema = z.union([
37+
z.object({ kind: z.literal(`comment`), key: z.string() }),
38+
z.object({
39+
kind: z.literal(`timeline`),
40+
collection: z.enum([
41+
`inbox`,
42+
`run`,
43+
`text`,
44+
`tool_call`,
45+
`wake`,
46+
`signal`,
47+
`manifest`,
48+
]),
49+
key: z.string(),
50+
run_id: z.string().optional(),
51+
}),
52+
])
53+
54+
const commentSnapshotSchema = z.object({
55+
label: z.string(),
56+
text: z.string().optional(),
57+
from: z.string().optional(),
58+
timestamp: z.string().optional(),
59+
collection: z.string().optional(),
60+
})
61+
62+
export const commentSchema = z.object({
63+
key: z.string().optional(),
64+
body: z.string(),
65+
timestamp: z.string(),
66+
reply_to: commentTargetSchema.optional(),
67+
target_snapshot: commentSnapshotSchema.optional(),
68+
})
69+
70+
/**
71+
* Contract identifier for the canonical comments collection. The server
72+
* reserves the `comments` collection name for this contract, and the UI
73+
* only surfaces comment affordances for entity types whose registration
74+
* advertises it — so an agent's unrelated `comments` state can never be
75+
* mistaken for platform comments.
76+
*/
77+
export const COMMENTS_CONTRACT = `comments/v1`
78+
79+
export const commentsCollection: CollectionDefinition = {
80+
schema: commentSchema,
81+
type: `state:comments`,
82+
primaryKey: `key`,
83+
externallyWritable: true,
84+
contract: COMMENTS_CONTRACT,
85+
// Insert-only: comments are append-only events. Update/delete would let a
86+
// client overwrite or remove another principal's comment by key. Edit and
87+
// soft-delete flows are deferred; their ops can be added here when they land.
88+
operations: [`insert`],
89+
}

packages/agents-runtime/src/create-handler.ts

Lines changed: 116 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
1717
import type { WebhookSignatureVerifierConfig } from './webhook-signature'
1818
import type {
1919
AgentTool,
20+
AnyEntityDefinition,
2021
EntityStreamDBWithActions,
2122
HeadersProvider,
2223
ProcessWakeConfig,
@@ -207,6 +208,120 @@ export interface RuntimeDebugState {
207208
export type RuntimeHandlerConfig = RuntimeRouterConfig
208209
export type RuntimeHandlerResult = RuntimeHandler
209210

211+
const JSON_SCHEMA_KEYWORDS = [
212+
`type`,
213+
`properties`,
214+
`items`,
215+
`enum`,
216+
`oneOf`,
217+
`anyOf`,
218+
`allOf`,
219+
`additionalProperties`,
220+
] as const
221+
222+
function stripSchemaKeyword(
223+
jsonSchema: Record<string, unknown>
224+
): Record<string, unknown> {
225+
const { $schema: _schema, ...rest } = jsonSchema
226+
return rest
227+
}
228+
229+
function toJsonSchema(schema: unknown): Record<string, unknown> {
230+
if (!schema || typeof schema !== `object` || Array.isArray(schema)) {
231+
return {}
232+
}
233+
234+
const standardSchema = schema as {
235+
[`~standard`]?: {
236+
jsonSchema?: {
237+
input?: () => unknown
238+
}
239+
}
240+
toJSONSchema?: () => Record<string, unknown>
241+
}
242+
243+
const standardJsonSchema = standardSchema[`~standard`]?.jsonSchema?.input?.()
244+
if (standardJsonSchema) {
245+
return stripSchemaKeyword(standardJsonSchema as Record<string, unknown>)
246+
}
247+
248+
if (typeof standardSchema.toJSONSchema === `function`) {
249+
return stripSchemaKeyword(standardSchema.toJSONSchema())
250+
}
251+
252+
if (`~standard` in standardSchema) {
253+
return {}
254+
}
255+
256+
const jsonSchemaLike = schema as Record<string, unknown>
257+
if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) {
258+
return stripSchemaKeyword(jsonSchemaLike)
259+
}
260+
261+
return zodToJsonSchema(schema as any, { target: `jsonSchema7` })
262+
}
263+
264+
function mapSchemas(
265+
schemas: Record<string, unknown>
266+
): Record<string, Record<string, unknown>> {
267+
return Object.fromEntries(
268+
Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)])
269+
)
270+
}
271+
272+
export function buildEntityTypeRegistrationBody(
273+
name: string,
274+
definition: AnyEntityDefinition
275+
): Record<string, unknown> {
276+
const stateEntries = definition.state ? Object.entries(definition.state) : []
277+
278+
const stateSchemas = Object.fromEntries(
279+
stateEntries.map(([collectionName, def]) => [
280+
def.type ?? `state:${collectionName}`,
281+
toJsonSchema(def.schema ?? passthrough()),
282+
])
283+
)
284+
285+
const externallyWritableCollections: Record<
286+
string,
287+
{ type: string; contract?: string; operations?: Array<string> }
288+
> = {}
289+
for (const [collectionName, def] of stateEntries) {
290+
if (!def.externallyWritable) continue
291+
externallyWritableCollections[collectionName] = {
292+
type: def.type ?? `state:${collectionName}`,
293+
...(def.contract && { contract: def.contract }),
294+
...(def.operations && { operations: def.operations }),
295+
}
296+
}
297+
298+
const body: Record<string, unknown> = {
299+
name,
300+
description: definition.description ?? `${name} entity`,
301+
...(definition.creationSchema && {
302+
creation_schema: toJsonSchema(definition.creationSchema),
303+
}),
304+
...(definition.inboxSchemas && {
305+
inbox_schemas: mapSchemas(definition.inboxSchemas),
306+
}),
307+
...(definition.slashCommands && {
308+
slash_commands: definition.slashCommands,
309+
}),
310+
state_schemas: {
311+
...DEFAULT_STATE_SCHEMAS,
312+
...stateSchemas,
313+
...(definition.stateSchemas ? mapSchemas(definition.stateSchemas) : {}),
314+
},
315+
...(Object.keys(externallyWritableCollections).length > 0 && {
316+
externally_writable_collections: externallyWritableCollections,
317+
}),
318+
...(definition.permissionGrants && {
319+
permission_grants: definition.permissionGrants,
320+
}),
321+
}
322+
return body
323+
}
324+
210325
export function createRuntimeRouter(
211326
config: RuntimeRouterConfig
212327
): RuntimeRouter {
@@ -413,110 +528,18 @@ export function createRuntimeRouter(
413528
return handleWebhookRequest(request)
414529
}
415530

416-
const stripSchemaKeyword = (
417-
jsonSchema: Record<string, unknown>
418-
): Record<string, unknown> => {
419-
const { $schema: _schema, ...rest } = jsonSchema
420-
return rest
421-
}
422-
423-
const JSON_SCHEMA_KEYWORDS = [
424-
`type`,
425-
`properties`,
426-
`items`,
427-
`enum`,
428-
`oneOf`,
429-
`anyOf`,
430-
`allOf`,
431-
`additionalProperties`,
432-
] as const
433-
434-
const toJsonSchema = (schema: unknown): Record<string, unknown> => {
435-
if (!schema || typeof schema !== `object` || Array.isArray(schema)) {
436-
return {}
437-
}
438-
439-
const standardSchema = schema as {
440-
[`~standard`]?: {
441-
jsonSchema?: {
442-
input?: () => unknown
443-
}
444-
}
445-
toJSONSchema?: () => Record<string, unknown>
446-
}
447-
448-
const standardJsonSchema =
449-
standardSchema[`~standard`]?.jsonSchema?.input?.()
450-
if (standardJsonSchema) {
451-
return stripSchemaKeyword(standardJsonSchema as Record<string, unknown>)
452-
}
453-
454-
if (typeof standardSchema.toJSONSchema === `function`) {
455-
return stripSchemaKeyword(standardSchema.toJSONSchema())
456-
}
457-
458-
if (`~standard` in standardSchema) {
459-
return {}
460-
}
461-
462-
const jsonSchemaLike = schema as Record<string, unknown>
463-
if (JSON_SCHEMA_KEYWORDS.some((keyword) => keyword in jsonSchemaLike)) {
464-
return stripSchemaKeyword(jsonSchemaLike)
465-
}
466-
467-
return zodToJsonSchema(schema as any, { target: `jsonSchema7` })
468-
}
469-
470531
const registerTypes = async (): Promise<void> => {
471532
const types = getRegisteredTypes()
472533
const registered: Array<string> = []
473534
const failed: Array<string> = []
474535
const totalStart = performance.now()
475536
const effectiveConcurrency = Math.max(1, registrationConcurrency ?? 8)
476537

477-
const mapSchemas = (
478-
schemas: Record<string, unknown>
479-
): Record<string, Record<string, unknown>> =>
480-
Object.fromEntries(
481-
Object.entries(schemas).map(([k, v]) => [k, toJsonSchema(v)])
482-
)
483-
484538
await forEachWithConcurrency(types, effectiveConcurrency, async (entry) => {
485539
const registrationStart = performance.now()
486540
const { name, definition } = entry
487541

488-
const stateSchemas = definition.state
489-
? Object.fromEntries(
490-
Object.entries(definition.state).map(([collectionName, def]) => [
491-
def.type ?? `state:${collectionName}`,
492-
toJsonSchema(def.schema ?? passthrough()),
493-
])
494-
)
495-
: {}
496-
497-
const body: Record<string, unknown> = {
498-
name,
499-
description: definition.description ?? `${name} entity`,
500-
...(definition.creationSchema && {
501-
creation_schema: toJsonSchema(definition.creationSchema),
502-
}),
503-
...(definition.inboxSchemas && {
504-
inbox_schemas: mapSchemas(definition.inboxSchemas),
505-
}),
506-
...(definition.slashCommands && {
507-
slash_commands: definition.slashCommands,
508-
}),
509-
state_schemas: {
510-
...DEFAULT_STATE_SCHEMAS,
511-
...stateSchemas,
512-
...(definition.stateSchemas
513-
? mapSchemas(definition.stateSchemas)
514-
: {}),
515-
},
516-
...(definition.permissionGrants && {
517-
permission_grants: definition.permissionGrants,
518-
}),
519-
}
542+
const body = buildEntityTypeRegistrationBody(name, definition)
520543

521544
const defaultDispatchPolicy = defaultDispatchPolicyForType?.(name)
522545

0 commit comments

Comments
 (0)