Skip to content
Merged
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
37 changes: 9 additions & 28 deletions packages/core/src/agent-operations/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { SceneMaterialId } from '../schema/scene-material'
import { applySceneChanges, mergeSceneChanges } from './apply-changes'
import { requireFurnishing } from './design-brief'
import { referencedSceneMaterialIds } from './scene-materials'
import type { AgentOperation, AgentOperationOutcome, SceneChanges } from './types'
import type { AgentOperation, AgentOperationSteps, SceneChanges } from './types'

type BatchInput = {
calls: { tool: string; input?: Record<string, unknown> }[]
Expand Down Expand Up @@ -41,23 +41,19 @@ function prepareCall(
return { operation, input: parsed.data }
}

/** A batch one call at a time: each resume runs the next call, and the last returns the outcome. */
export type BatchSteps = (
...args: Parameters<AgentOperation<BatchInput>>
) => Generator<void, AgentOperationOutcome, void>

/**
* `run_batch`: shared tools in order, each on the scene the calls before it left, their changes
* merged into one set — one undo step, one write on either surface. The user: the model spent a
* round trip per step on what could be serialised (deleting balcony walls one by one, the same
* build on every floor). Pre-check and operation refusals follow the same per-call policy.
* It stops after each call, so a host that can let other work run between a batch's calls does
* (`createBatchOperation` runs it through in one go).
* It stops after each call, and where a call that runs in steps (`stepped`) does, so a host that
* can let other work run meanwhile does (`AGENT_OPERATIONS.run_batch` runs it through in one go).
*/
export function createBatchSteps(
operations: Readonly<Record<string, AgentOperation<never>>>,
contracts: readonly Contract[],
): BatchSteps {
stepped: Readonly<Record<string, AgentOperationSteps<never>>> = {},
): AgentOperationSteps<BatchInput> {
return function* (nodes, { calls, onRefusal = 'stop' }, context) {
let working = nodes
let workingContext = context
Expand All @@ -71,10 +67,10 @@ export function createBatchSteps(
catalog: workingContext.catalog,
})
const { operation, input } = prepareCall(call, index, operations, contracts)
const { result, changes } = operation(working, input as never, {
...workingContext,
batchCall: index,
})
const callContext = { ...workingContext, batchCall: index }
const { result, changes } = Object.hasOwn(stepped, call.tool)
? yield* stepped[call.tool]!(working, input as never, callContext)
: operation(working, input as never, callContext)
if (changes) {
sets.push(changes)
working = applySceneChanges(working, changes)
Expand Down Expand Up @@ -143,18 +139,3 @@ export function createBatchSteps(
}
}
}

/** `run_batch` as one synchronous operation: every call, back to back. */
export function createBatchOperation(
operations: Readonly<Record<string, AgentOperation<never>>>,
contracts: readonly Contract[],
): AgentOperation<BatchInput> {
const steps = createBatchSteps(operations, contracts)
return (nodes, input, context) => {
const batch = steps(nodes, input, context)
for (;;) {
const step = batch.next()
if (step.done) return step.value
}
}
}
24 changes: 18 additions & 6 deletions packages/core/src/agent-operations/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { AGENT_TOOL_CONTRACTS, BATCHABLE_TOOLS } from '../agent-tools'
import { inOneGo } from '../lib/steps'
import { addColumn } from './add-column'
import { addFence } from './add-fence'
import { addLevel } from './add-level'
import { addObjectInBatch } from './add-object'
import { addWall } from './add-wall'
import { viewAssets } from './asset-views'
import { createBatchOperation, createBatchSteps } from './batch'
import { createBatchSteps } from './batch'
import { addCornerWindow } from './corner-window'
import { createRoof } from './create-roof'
import { createRoom } from './create-room'
Expand Down Expand Up @@ -33,14 +34,15 @@ import { setCheckpoint } from './set-checkpoint'
import { setGroundLevel } from './set-ground-level'
import { getSite, placeBuilding } from './site'
import { fitStair, measureStairOperation } from './stairs'
import type { AgentOperation, AgentOperations } from './types'
import type { AgentOperation, AgentOperationSteps, AgentOperations } from './types'
import { createUnit, listUnits, setUnitMembers } from './units'
import { verifyScene } from './verify-scene'
import { verifyScene, verifySceneSteps } from './verify-scene'
import { setWallType } from './wall-type'
import './room-checks'
import './unreachable-rooms-check'
import './usage-checks'

export { inOneGo, type Steps } from '../lib/steps'
export * from './achieved'
export * from './add-column'
export * from './add-fence'
Expand Down Expand Up @@ -164,14 +166,24 @@ const BATCHED_OPERATIONS = Object.fromEntries(
BATCHABLE_TOOLS.map((name) => [name, BATCH_OPERATIONS[name]]),
) as Record<(typeof BATCHABLE_TOOLS)[number], AgentOperation<never>>

const STEPPED_OPERATIONS = { verify_scene: verifySceneSteps }
const batchSteps = createBatchSteps(BATCHED_OPERATIONS, AGENT_TOOL_CONTRACTS, STEPPED_OPERATIONS)

/** Each shared agent tool's operation, by tool name: what every surface executes. */
export const AGENT_OPERATIONS = {
...SINGLE_OPERATIONS,
run_batch: createBatchOperation(BATCHED_OPERATIONS, AGENT_TOOL_CONTRACTS),
run_batch: (nodes, input, context) => inOneGo(batchSteps(nodes, input, context)),
} as const satisfies AgentOperations

/** `run_batch` one call at a time, for a host that lets other work run between a batch's calls. */
export const RUN_BATCH_STEPS = createBatchSteps(BATCHED_OPERATIONS, AGENT_TOOL_CONTRACTS)
/**
* The operations that take seconds on a large building, in steps, by tool name: a batch after each
* of its calls, verify_scene after each of its checks. For a host that lets other work run between
* the steps; each answers what its operation in `AGENT_OPERATIONS` answers.
*/
export const AGENT_OPERATION_STEPS: Readonly<Record<string, AgentOperationSteps<never>>> = {
...STEPPED_OPERATIONS,
run_batch: batchSteps,
}

export {
type AssetViewFacts,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/agent-operations/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ZodType } from 'zod'
import type { DesignBrief } from '../agent-tools/design-brief'
import type { Steps } from '../lib/steps'
import type {
AnyNode,
Collection,
Expand Down Expand Up @@ -115,5 +116,14 @@ export type AgentOperation<Input = never> = (
context: AgentContext,
) => AgentOperationOutcome

/**
* An operation that takes seconds on a large building, in steps: a batch's calls, verify_scene's
* checks. A host that can let other work run between them resumes it one step at a time; the
* operation of the same name runs it through (`inOneGo`), with the same outcome.
*/
export type AgentOperationSteps<Input = never> = (
...args: Parameters<AgentOperation<Input>>
) => Steps<AgentOperationOutcome>

/** Operations by tool name: core's `AGENT_OPERATIONS`, or a plugin's `./agent-operations` export. */
export type AgentOperations = Readonly<Record<string, AgentOperation>>
4 changes: 2 additions & 2 deletions packages/core/src/agent-operations/usage-checks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { isUsageType, ROOM_ENTRY_TYPE, ROOM_FOLDED_TYPES } from '../agent-tools/verify-scene-usage'
import { USAGE_TYPES, type UsageIssue, usageIssues } from '../spatial/usage-field/findings'
import { USAGE_TYPES, usageIssueSteps } from '../spatial/usage-field/findings'
import { sdfChecksOn } from '../spatial/usage-field/flag'
import { registerSceneCheck } from './verify-scene'

Expand All @@ -24,5 +24,5 @@ registerSceneCheck({
name: 'usage',
order: 60,
run: (nodes, input) =>
sdfChecksOn() ? (usageIssues(nodes, { types: askedTypes(input.type) }) as UsageIssue[]) : [],
sdfChecksOn() ? usageIssueSteps(nodes, { types: askedTypes(input.type) }) : [],
})
12 changes: 11 additions & 1 deletion packages/core/src/agent-operations/verify-scene-typed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
import { setSdfChecks } from '../spatial/usage-field/flag'
import './index'
import type { SceneNodes } from './types'
import { verifyScene } from './verify-scene'
import { verifyScene, verifySceneSteps } from './verify-scene'

type Pt = [number, number]
type Report = { issues: { type: string }[] }
Expand Down Expand Up @@ -131,6 +131,16 @@ describe('verify_scene asked for one finding type, with the usage checks on', ()
expect(answer(report(input), input)).toEqual(answer(report({}), input))
})

test('taken a step at a time, it stops between its checks and answers the same report', () => {
const steps = verifySceneSteps(nodes, {}, context)
let stops = 0
let step = steps.next()
for (; !step.done; step = steps.next()) stops++
// More stops than checks: the circulation measurement stops after each floor.
expect(stops).toBeGreaterThan(5)
expect(step.value.result).toEqual(report({}))
})

test('the report for a type that is not a usage finding holds no usage findings', () => {
const typed = report({ type: 'door_blocked' }) as Report
expect(typed.issues.filter((issue) => issue.type.startsWith('usage_'))).toEqual([])
Expand Down
34 changes: 25 additions & 9 deletions packages/core/src/agent-operations/verify-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HIDDEN_SITE_NOTE } from '../lib/node-visibility'
import { wallSupportForNodes } from '../lib/opening-floor-datum'
import { detectOpenWallEnds, type OpenWallEnd } from '../lib/room-graph'
import { siteBrief } from '../lib/site-placement'
import { inOneGo, type Steps } from '../lib/steps'
import { nodeRegistry } from '../registry'
import {
type AnyNode,
Expand Down Expand Up @@ -57,7 +58,7 @@ import {
queryNodes,
} from './scene-queries'
import { reviewSince } from './scene-review'
import type { AgentContext, AgentOperation, SceneNodes } from './types'
import type { AgentContext, AgentOperation, AgentOperationSteps, SceneNodes } from './types'
import type { RoomUnreachableIssue } from './unreachable-rooms-check'
import {
type WallEndShortIssue,
Expand Down Expand Up @@ -120,12 +121,17 @@ export type VerifySceneInput = Readonly<Record<string, unknown>>
/**
* A check verify_scene runs beyond its own, registered by the module that knows it (the facade
* checks, the photo's), so verify_scene imports none of them. Checks before CHECKPOINT_ORDER run
* ahead of the checkpoint's comparison, the rest after it.
* ahead of the checkpoint's comparison, the rest after it. A check that takes seconds on a large
* building answers in steps, and verify_scene stops where it does.
*/
export type SceneCheck = {
name: string
order: number
run: (nodes: SceneNodes, input: VerifySceneInput, context: AgentContext) => SceneIssue[]
run: (
nodes: SceneNodes,
input: VerifySceneInput,
context: AgentContext,
) => SceneIssue[] | Steps<SceneIssue[]>
}

export const CHECKPOINT_ORDER = 100
Expand Down Expand Up @@ -336,11 +342,15 @@ function checkSchemas(scene: SceneNodes, hostSchema: AgentContext['nodeSchema'])
/** A flight of this many risers or fewer is a step up to a door, not a staircase: no going or comfort target applies. */
const SHORT_FLIGHT_STEPS = 3

export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (
export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (scene, input, context) =>
inOneGo(verifySceneSteps(scene, input, context))

/** `verify_scene`, a step for each registered check and for each step of one that has them. */
export const verifySceneSteps: AgentOperationSteps<VerifySceneInput | undefined> = function* (
scene,
input,
context,
) => {
) {
const { errors, unknownKinds, unread, measured: nodes } = checkSchemas(scene, context.nodeSchema)
const onLevel = (levelId: string) => nodesOnLevel(nodes, levelId)
const ofType = <T extends AnyNode['type']>(content: readonly AnyNode[], type: T) =>
Expand Down Expand Up @@ -798,8 +808,15 @@ export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (
issues.push(...layoutIssuesFromScene(Object.values(settled)))
issues.push(...ceilingObjectObservations(settled))
issues.push(...wallObjectObservations(settled))
for (const check of checks)
if (check.order < CHECKPOINT_ORDER) issues.push(...check.run(settled, input ?? {}, context))
const registered = function* (beforeCheckpoint: boolean): Steps<void> {
for (const check of checks) {
if (check.order < CHECKPOINT_ORDER !== beforeCheckpoint) continue
const found = check.run(settled, input ?? {}, context)
issues.push(...(Array.isArray(found) ? found : yield* found))
yield
}
}
yield* registered(true)
// What the edits since the host's checkpoint lost, by place.
const hosted = context.checkpoint
? changesSince(context.checkpoint as SceneCheckpoint, scene)
Expand All @@ -810,8 +827,7 @@ export const verifyScene: AgentOperation<VerifySceneInput | undefined> = (
const session = context.session ?? EMPTY_SESSION
const since = typeof input?.since === 'string' ? reviewSince(session, input.since, scene) : null
if (since?.places) issues.push(...since.places.issues)
for (const check of checks)
if (check.order >= CHECKPOINT_ORDER) issues.push(...check.run(settled, input ?? {}, context))
yield* registered(false)

const occupiedStoryCount = levels.filter((level) => level.isOccupiedStory).length
issues.splice(0, issues.length, ...withoutDuplicatePockets(issues))
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/lib/steps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Work in steps: each resume runs the next piece, and the last returns the result. A long
* computation written this way can be resumed one piece at a time by a caller that lets other work
* run in between; `inOneGo` runs it through.
*/
export type Steps<Result> = Generator<void, Result, void>

export function inOneGo<Result>(steps: Steps<Result>): Result {
for (;;) {
const step = steps.next()
if (step.done) return step.value
}
}
31 changes: 24 additions & 7 deletions packages/core/src/spatial/usage-field/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type ThresholdSource,
USAGE_INDICATORS,
} from '../../building/code-profile'
import { inOneGo, type Steps } from '../../lib/steps'
import type { AnyNode, DoorNode, ItemNode } from '../../schema'
import { bedIssues } from './beds'
import { blockedOpeningIssues } from './blocked'
Expand All @@ -13,7 +14,7 @@ import { fixtureClearanceIssues } from './fixtures'
import { m, nameOf, quote, unique } from './labels'
import {
type MeasureOptions,
measureUsage,
measureUsageSteps,
type ObstacleRef,
type Passage,
type RoomMeasure,
Expand Down Expand Up @@ -298,12 +299,12 @@ function pocketIssue(nodes: Nodes, room: RoomMeasure, from: string): UsageIssue
}

/** The passages, turning circles and pockets of every group of rooms, measured on one clearance field. */
function circulationIssues(
function* circulationIssues(
nodes: Nodes,
shapes: UsageShapes,
options: MeasureOptions,
): UsageIssue[] {
const measure = measureUsage(nodes, shapes, options)
): Steps<UsageIssue[]> {
const measure = yield* measureUsageSteps(nodes, shapes, options)
const issues: UsageIssue[] = []
for (const group of measure.groups) {
const from = group.source.kind === 'door' ? 'the entrance' : 'the stair arrival'
Expand Down Expand Up @@ -346,11 +347,16 @@ function circulationIssues(
/**
* Each measurement with the finding types it gives, in the order their findings are listed. On a
* building of a few hundred rooms the circulation measurement takes seconds and the others a
* fraction of one, so a question about some types takes the measurements that give them alone.
* fraction of one, so a question about some types takes the measurements that give them alone,
* and the circulation measurement is taken in steps.
*/
const MEASUREMENTS: {
types: readonly UsageIssue['type'][]
run: (nodes: Nodes, shapes: UsageShapes, options: MeasureOptions) => UsageIssue[]
run: (
nodes: Nodes,
shapes: UsageShapes,
options: MeasureOptions,
) => UsageIssue[] | Steps<UsageIssue[]>
}[] = [
{
types: ['usage_pocket', 'usage_passage_narrow', 'usage_turning_narrow'],
Expand Down Expand Up @@ -386,11 +392,22 @@ export type UsageOptions = MeasureOptions & {
* not a compliance check.
*/
export function usageIssues(nodes: Nodes, options: UsageOptions = {}): UsageIssue[] {
return inOneGo(usageIssueSteps(nodes, options))
}

/** `usageIssues`, a step for each measurement and for each floor of the circulation one. */
export function* usageIssueSteps(nodes: Nodes, options: UsageOptions = {}): Steps<UsageIssue[]> {
const { types, ...measure } = options
const asked = types
? MEASUREMENTS.filter((measurement) => measurement.types.some((type) => types.has(type)))
: MEASUREMENTS
if (asked.length === 0) return []
const shapes = usageShapes(nodes)
return asked.flatMap((measurement) => measurement.run(nodes, shapes, measure))
const issues: UsageIssue[] = []
for (const measurement of asked) {
const found = measurement.run(nodes, shapes, measure)
issues.push(...(Array.isArray(found) ? found : yield* found))
yield
}
return issues
}
Loading
Loading