Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion skills/behavioral/references/behavioral.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,13 @@ import type {
`useAddHandler`, no `sendTrace`, no generic type parameter:

```ts
const { useAddThread, trigger, useTrace } = behavioral({ instanceId?: string })
const { useAddThread, trigger, useTrace } = behavioral({ sessionId?: string })
```

The optional `sessionId` is host-supplied session identity stamped on every
trace alongside the self-minted `instanceId` (an ACP ingress host owns session
identity policy); absent it defaults to the `instanceId`.

Threads are JSON objects: `{ label: string, rules: Idioms[], once?: true }`.
Each idiom is one sync point with `request` (propose an event), `waitFor`
(block until an event), `block` (forbid an event), `interrupt` (terminate the
Expand Down
23 changes: 22 additions & 1 deletion src/behavioral/behavioral.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@
* and threads to run. If no events can be selected (either because all requests are blocked
* or there are no requests), the program will pause until an event is admitted via `trigger`.
*
* @param options - Optional factory options.
* @param options.sessionId - Host-supplied session identity stamped on every
* trace alongside `instanceId`. The engine never mints or returns session
* ids — an ingress host (e.g. the ACP host) owns session identity policy.
* Defaults to the self-minted `instanceId` when omitted.
*
* **Channel invariant:** a selected event carries `ingress: true` iff it was admitted
* externally through `trigger`; everything internal (dispatch-bridge results, transform
* targets, `threads.registered`) arrives as a thread request added through `useAddThread`.
Expand All @@ -97,8 +103,10 @@
* absent = either). `trigger` is therefore external admission plus one super-step,
* nothing else.
*/
export const behavioral = () => {
export const behavioral = (options?: { sessionId?: string }) => {
const instanceId = ueid('bp_')
/** @internal Host session identity — accepted at factory time, never minted. */
const sessionId = options?.sessionId ?? instanceId
/**
* @internal
* Set of threads that have yielded and are waiting for event selection.
Expand Down Expand Up @@ -133,6 +141,7 @@
step: stepId,
ingress,
instanceId,
sessionId,
})
advanceRunningToPending(running, pending)
selectNextEvent()
Expand All @@ -159,6 +168,7 @@
timestamp: Date.now(),
step,
instanceId,
sessionId,
threads: [...pending].map(({ generator: _, ...rest }) => rest),
})

Expand All @@ -168,6 +178,7 @@
timestamp: Date.now(),
step,
instanceId,
sessionId,
...frontier,
})

Expand All @@ -185,6 +196,7 @@
timestamp: Date.now(),
step,
instanceId,
sessionId,
})
}
if (frontier.status === FRONTIER_STATUS.idle) {
Expand All @@ -193,6 +205,7 @@
timestamp: Date.now(),
step,
instanceId,
sessionId,
})
}
}
Expand All @@ -216,13 +229,15 @@
kind: TRACE_MESSAGE_KINDS.thread_added,
timestamp: Date.now(),
instanceId,
sessionId,
thread: args,
})
} catch (err) {
sendTrace({
kind: TRACE_MESSAGE_KINDS.add_thread_error,
timestamp: Date.now(),
instanceId,
sessionId,
error: [err instanceof Error ? err.message : String(err)],
space,
})
Expand All @@ -232,6 +247,7 @@
kind: TRACE_MESSAGE_KINDS.add_thread_error,
timestamp: Date.now(),
instanceId,
sessionId,
error: validateThread.errors ?? [],
...(typeof attemptedSpace === 'string' && { space: attemptedSpace }),
})
Expand All @@ -258,6 +274,7 @@
pending,
sendTrace,
instanceId,
sessionId,
step: stepId,
})
if (transformers.length) {
Expand All @@ -266,6 +283,7 @@
timestamp: Date.now(),
step: stepId,
instanceId,
sessionId,
transformers,
})
for (const { query, target, thread, space } of transformers) {
Expand All @@ -284,6 +302,7 @@
timestamp: Date.now(),
step: stepId,
instanceId,
sessionId,
transformer: { query, target, thread, space },
reason: result.reason,
...(result.stderr !== undefined && { stderr: result.stderr }),
Expand All @@ -297,6 +316,7 @@
timestamp: Date.now(),
step: stepId,
instanceId,
sessionId,
selected: selectedEvent,
})
/**
Expand Down Expand Up @@ -326,6 +346,7 @@
kind: TRACE_MESSAGE_KINDS.trigger_error,
timestamp: Date.now(),
instanceId,
sessionId,
error: validateBPEvent.errors ?? [],
...(typeof attemptedSpace === 'string' ? { space: attemptedSpace } : {}),
})
Expand Down
25 changes: 25 additions & 0 deletions src/behavioral/behavioral.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,14 +319,39 @@ export type Threads = Thread[]
* `TRACE_MESSAGE_KINDS` so narrowing by `kind` remains unambiguous in the
* unified `Trace | T` stream.
*
* The two id axes are separate and both live on the wire: `instanceId` is the
* per-process identity the engine self-mints; `sessionId` is the host's
* session identity (an ACP/ingress host mints and loads sessions), defaulted
* to the `instanceId` when no host supplies one. The engine accepts a session
* id at factory time — it never mints one and never returns ids.
*
* @see {@link TraceBaseSchema} for the runtime (JSON-schema) mirror
* @see {@link Trace} for the engine's closed trace union
*/
type TraceBase = {
kind: string
timestamp: number
instanceId: string
sessionId: string
}

/**
* Wire schema for the fields every trace carries — the runtime mirror of
* {@link TraceBase}. The one home for the trace wire's common shape: per-kind
* trace validators derive from this (spread the properties, extend `required`)
* instead of hand-mirroring the fields.
*/
export const TraceBaseSchema = {
type: 'object',
properties: {
kind: { type: 'string' },
timestamp: { type: 'number' },
instanceId: { type: 'string' },
sessionId: { type: 'string' },
},
required: ['kind', 'timestamp', 'instanceId', 'sessionId'],
} as const

// ---------------------------------------------------------------------------
// Trace kinds
// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions src/behavioral/behavioral.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,15 @@ export const resumePendingThreadsForSelectedEvent = ({
selectedEvent,
sendTrace,
instanceId,
sessionId,
step,
}: {
running: Set<RunningBid>
pending: Set<PendingBid>
selectedEvent: CandidateBid
sendTrace?: SendTrace
instanceId: string
sessionId: string
step: number
}) => {
const transformers: Transformer[] = []
Expand All @@ -147,6 +149,7 @@ export const resumePendingThreadsForSelectedEvent = ({
timestamp: Date.now(),
step,
instanceId,
sessionId,
selected: selectedEvent,
threadLabel: label,
})
Expand Down
34 changes: 25 additions & 9 deletions src/behavioral/tests/schemas.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { describe, expect, test } from 'bun:test'

import { ajv, validateBPEvent, validateThread, validateTransformEvaluation } from '../behavioral.types.ts'
import {
ajv,
TraceBaseSchema,
validateBPEvent,
validateThread,
validateTransformEvaluation,
} from '../behavioral.types.ts'

// Derived from TraceBaseSchema — the one home for the trace wire's common
// shape — extended per-kind with the discriminating `kind` and `step`.
const compileTraceValidator = (kind: string) =>
ajv.compile({
type: 'object',
properties: {
kind: { const: kind },
timestamp: { type: 'number' },
instanceId: { type: 'string' },
step: { type: 'integer' },
},
required: ['kind', 'timestamp', 'instanceId', 'step'],
...TraceBaseSchema,
properties: { ...TraceBaseSchema.properties, kind: { const: kind }, step: { type: 'integer' } },
required: [...TraceBaseSchema.required, 'step'],
})

describe('behavioral schemas', () => {
Expand Down Expand Up @@ -65,6 +68,7 @@ describe('behavioral schemas', () => {
kind: 'selection',
timestamp: 3,
instanceId: 'bp_test',
sessionId: 'sess_test',
step: 3,
selected: { type: 'event', detail: { value: 1 } },
}
Expand All @@ -73,6 +77,18 @@ describe('behavioral schemas', () => {
expect(narrowed.selected.type).toBe('event')
})

test('Trace validators reject missing sessionId', () => {
expect(
compileTraceValidator('selection')({
kind: 'selection',
timestamp: 0,
instanceId: 'bp_test',
step: 0,
selected: { type: 'event' },
}),
).toBe(false)
})

test('Trace validators reject unknown kinds and missing step', () => {
expect(compileTraceValidator('selection')({ kind: 'worker', response: { id: 'worker-1' }, step: 0 })).toBe(false)
expect(compileTraceValidator('deadlock')({ kind: 'deadlock', timestamp: 0, instanceId: 'bp_test' })).toBe(false)
Expand Down
37 changes: 37 additions & 0 deletions src/behavioral/tests/session-id.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { describe, expect, test } from 'bun:test'

import { behavioral } from '../behavioral.ts'
import type { Trace } from '../behavioral.types.ts'

const runProgram = (options?: Parameters<typeof behavioral>[0]): { traces: Trace[]; instanceId: string } => {
const traces: Trace[] = []
const program = behavioral(options)
program.useTrace((trace) => {
traces.push(trace)
})
program.addThread({ label: 'greeter', once: true, rules: [{ request: { type: 'hello' } }] })
program.trigger({ type: 'wake' })
const first = traces[0] as Trace | undefined
return { traces, instanceId: first?.instanceId ?? '' }
}

describe('session id wiring', () => {
test('a host-supplied sessionId is stamped on every trace alongside instanceId', () => {
const { traces, instanceId } = runProgram({ sessionId: 'sess_host_1' })
expect(traces.length).toBeGreaterThan(0)
for (const trace of traces) {
expect(trace.sessionId).toBe('sess_host_1')
expect(trace.instanceId).toBe(instanceId)
expect(trace.instanceId).not.toBe('sess_host_1')
}
})

test('without a host session id, every trace defaults sessionId to the instanceId', () => {
const { traces, instanceId } = runProgram()
expect(instanceId).not.toBe('')
expect(traces.length).toBeGreaterThan(0)
for (const trace of traces) {
expect(trace.sessionId).toBe(instanceId)
}
})
})
1 change: 1 addition & 0 deletions src/cli/tests/serve.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const selectionOf = (selected: { type: string; detail?: JsonObject; space?: stri
kind: TRACE_MESSAGE_KINDS.selection,
timestamp: 0,
instanceId: 'i',
sessionId: 'i',
step: 1,
selected: { priority: 0, ...selected },
})
Expand Down
1 change: 1 addition & 0 deletions src/cli/tests/trace-consumer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const selection = (detail: JsonObject, space?: string): SelectionTrace => ({
kind: TRACE_MESSAGE_KINDS.selection,
timestamp: 0,
instanceId: 'i',
sessionId: 'i',
step: 1,
selected: { priority: 0, type: 'shell_request', detail, ...(space === undefined ? {} : { space }) },
})
Expand Down
Loading
Loading