diff --git a/AGENTS.md b/AGENTS.md index 04e1ba8..a4e0bfd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,6 +4,7 @@ This repository follows a harness-oriented workflow: docs first, clear interface ## Fast Map - Canonical docs index: [docs/README.md](docs/README.md) +- Canonical rearchitecture decision: [docs/architecture/rearchitecture-decision.md](docs/architecture/rearchitecture-decision.md) - Current execution plan: [docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md](docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md) - Checkpoint board: [docs/checkpoints/mvp-status.md](docs/checkpoints/mvp-status.md) - CP10 verification matrix: [docs/checkpoints/cp10-verification.md](docs/checkpoints/cp10-verification.md) diff --git a/agents/capability-catalog.ts b/agents/capability-catalog.ts new file mode 100644 index 0000000..feb2a26 --- /dev/null +++ b/agents/capability-catalog.ts @@ -0,0 +1,62 @@ +import type { ResourceKind } from "./resource-kinds.js"; +import type { ToolPrimitive } from "./tool-primitives.js"; + +export type CapabilityDefinition = Readonly<{ + resourceSlots: Readonly>; + skills: readonly string[]; + grants: Readonly<{ + toolPrimitives: readonly ToolPrimitive[]; + }>; + guidance: readonly string[]; +}>; + +export const capabilityCatalog = { + "query-gravity-v1": { + resourceSlots: {}, + skills: ["query-gravity"], + grants: { + toolPrimitives: ["read", "bash"], + }, + guidance: [ + "- `query-gravity-v1`: inspect Gravity runtime metadata and run logs for debugging and operations evidence.", + ], + }, + "rollback-v1": { + resourceSlots: {}, + skills: ["rollback"], + grants: { + toolPrimitives: ["read", "bash"], + }, + guidance: [ + "- `rollback-v1`: apply file-scoped rollback procedures using non-destructive git patterns.", + ], + }, + "duckdb-analyst-v1": { + resourceSlots: { + warehouse: "duckdb", + }, + skills: ["duckdb-query"], + grants: { + toolPrimitives: ["read", "bash"], + }, + guidance: [ + "- `duckdb-analyst-v1`: execute SQL-backed analysis against the bound DuckDB warehouse resource.", + ], + }, + "knowledge-docs-review-v1": { + resourceSlots: { + docs: "knowledge-docs", + }, + skills: ["knowledge-docs-review"], + grants: { + toolPrimitives: ["read"], + }, + guidance: [ + "- `knowledge-docs-review-v1`: ground policy/process recommendations in the bound knowledge-docs resource.", + ], + }, +} as const satisfies Record; + +export type CapabilityCatalog = typeof capabilityCatalog; +export type CapabilityId = keyof CapabilityCatalog; +export type CapabilitySkillId = CapabilityCatalog[CapabilityId]["skills"][number]; diff --git a/agents/capability-compiler.ts b/agents/capability-compiler.ts new file mode 100644 index 0000000..e497617 --- /dev/null +++ b/agents/capability-compiler.ts @@ -0,0 +1,116 @@ +import { capabilityCatalog } from "./capability-catalog.js"; +import type { + AgentCapabilityBinding, + AgentResource, + CapabilityId, + CapabilitySkillId, +} from "./contracts.js"; +import type { ToolPrimitive } from "./tool-primitives.js"; + +export type CompiledCapability = Readonly<{ + capability: CapabilityId; + bindResources: Readonly>; + skillIds: readonly CapabilitySkillId[]; + resourceIds: readonly string[]; + grants: Readonly<{ + toolPrimitives: readonly ToolPrimitive[]; + }>; + guidance: readonly string[]; +}>; + +export type CompiledAgentCapabilities = Readonly<{ + capabilities: readonly CompiledCapability[]; + requiredSkillIds: readonly CapabilitySkillId[]; + requiredResources: readonly AgentResource[]; + toolPrimitives: readonly ToolPrimitive[]; + capabilityGuidance: readonly string[]; +}>; + +function unique(values: readonly T[]): T[] { + return Array.from(new Set(values)); +} + +function formatBoundResources(bindResources: Record): string { + const entries = Object.entries(bindResources) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([slot, resource]) => `${slot}=${resource.id}`); + + if (entries.length === 0) { + return "(none)"; + } + + return entries.join(", "); +} + +export function compileAgentCapabilities(input: { + resources: readonly AgentResource[]; + useCapabilities: readonly AgentCapabilityBinding[]; +}): CompiledAgentCapabilities { + const resourcesById = new Map(); + for (const resource of input.resources) { + resourcesById.set(resource.id, resource); + } + + const compiledCapabilities: CompiledCapability[] = []; + + for (const binding of input.useCapabilities) { + const definition = capabilityCatalog[binding.capability]; + const bindResources: Record = {}; + + for (const [slot, resourceId] of Object.entries(binding.bindResources)) { + const resource = resourcesById.get(resourceId); + if (!resource) { + throw new Error( + `Capability ${binding.capability} references unknown resource id: ${resourceId}`, + ); + } + bindResources[slot] = resource; + } + + const resourceIds = Object.values(bindResources) + .map((resource) => resource.id) + .sort(); + + const boundResourcesLine = formatBoundResources(bindResources); + const guidance = [ + ...definition.guidance, + `- Capability \`${binding.capability}\` bound resources: ${boundResourcesLine}`, + ]; + + compiledCapabilities.push({ + capability: binding.capability, + bindResources, + skillIds: [...definition.skills], + resourceIds, + grants: { + toolPrimitives: [...definition.grants.toolPrimitives], + }, + guidance, + }); + } + + const requiredSkillIds = unique( + compiledCapabilities.flatMap((capability) => capability.skillIds), + ); + const requiredResourceIds = unique( + compiledCapabilities.flatMap((capability) => capability.resourceIds), + ); + const requiredResources = requiredResourceIds + .map((resourceId) => resourcesById.get(resourceId)) + .filter((resource): resource is AgentResource => Boolean(resource)); + + const toolPrimitives = unique( + compiledCapabilities.flatMap((capability) => capability.grants.toolPrimitives), + ); + const capabilityGuidance = compiledCapabilities.flatMap( + (capability) => capability.guidance, + ); + + return { + capabilities: compiledCapabilities, + requiredSkillIds, + requiredResources, + toolPrimitives, + capabilityGuidance, + }; +} diff --git a/agents/compliance-helper/agent.ts b/agents/compliance-helper/agent.ts new file mode 100644 index 0000000..576890f --- /dev/null +++ b/agents/compliance-helper/agent.ts @@ -0,0 +1,80 @@ +import { defineAgent } from "../contracts.js"; + +export const complianceHelperAgent = defineAgent({ + id: "compliance-helper", + name: "Compliance Helper", + description: "Compliance review proof-of-concept agent.", + model: "claude-sonnet-4-5-20250929", + runtime: "host", + resources: [ + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ], + session: { + defaultMode: "thread", + }, + listen: [ + { + id: "slack-compliance-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + sessionMode: "thread", + match: { + command: "/compliance", + }, + enabled: true, + }, + { + id: "slack-compliance-mention", + kind: "message", + surface: "slack", + entrypoint: "app_mention", + sessionMode: "thread", + enabled: true, + }, + { + id: "slack-compliance-thread", + kind: "message", + surface: "slack", + entrypoint: "thread_reply", + match: { + threadOwnedByAgent: true, + }, + sessionMode: "thread", + enabled: true, + }, + { + id: "slack-compliance-dm", + kind: "message", + surface: "slack", + entrypoint: "direct_message", + sessionMode: "main", + enabled: true, + }, + ], + proactive: { + deliveryDefaults: { + surface: "slack", + mode: "channel_thread", + channelId: "C0AFYK6AVQR", + }, + triggers: [], + }, + useCapabilities: [ + { + capability: "query-gravity-v1", + }, + { + capability: "rollback-v1", + }, + { + capability: "knowledge-docs-review-v1", + bindResources: { + docs: "policy-docs", + }, + }, + ], +}); diff --git a/agents/contracts.ts b/agents/contracts.ts new file mode 100644 index 0000000..2ae08f2 --- /dev/null +++ b/agents/contracts.ts @@ -0,0 +1,925 @@ +import { + capabilityCatalog, + type CapabilityId, + type CapabilitySkillId, +} from "./capability-catalog.js"; +import type { ResourceKind } from "./resource-kinds.js"; + +export type SessionMode = "thread" | "main" | "isolated"; +export type AgentRuntime = "host" | "sandbox"; +export type IngressEntrypoint = + | "slash_command" + | "app_mention" + | "thread_reply" + | "direct_message"; + +export type SlackDeliveryTargetInput = + | { + surface: "slack"; + mode: "channel_thread"; + channelId: string; + } + | { + surface: "slack"; + mode: "dm"; + userId: string; + }; + +export type SlackDeliveryTarget = Readonly; + +export type QuietHoursInput = { + enabled?: boolean; + timezone: string; + startHour: number; + endHour: number; + daysOfWeek?: readonly number[]; +}; + +export type QuietHours = Readonly<{ + enabled: boolean; + timezone: string; + startHour: number; + endHour: number; + daysOfWeek?: readonly number[]; +}>; + +export type FrameworkConfigInput = { + infra: { + database: { + urlEnvVar: string; + }; + slack: { + appTokenEnvVar: string; + botTokenEnvVar: string; + }; + modelProvider: { + provider: "anthropic"; + apiKeyEnvVar: string; + }; + }; + defaults: { + model: string; + runtime: AgentRuntime; + sessionMode: SessionMode; + quietHours?: QuietHoursInput; + }; + paths: { + sharedRoot: string; + workspaceRoot: string; + }; +}; + +export type FrameworkConfig = Readonly<{ + infra: Readonly<{ + database: Readonly<{ + urlEnvVar: string; + }>; + slack: Readonly<{ + appTokenEnvVar: string; + botTokenEnvVar: string; + }>; + modelProvider: Readonly<{ + provider: "anthropic"; + apiKeyEnvVar: string; + }>; + }>; + defaults: Readonly<{ + model: string; + runtime: AgentRuntime; + sessionMode: SessionMode; + quietHours?: QuietHours; + }>; + paths: Readonly<{ + sharedRoot: string; + workspaceRoot: string; + }>; +}>; + +export type IngressMatchInput = { + command?: string; + channelId?: string; + userId?: string; + isDirectMessage?: boolean; + threadOwnedByAgent?: boolean; +}; + +export type IngressMatch = Readonly<{ + command?: string; + channelId?: string; + userId?: string; + isDirectMessage?: boolean; + threadOwnedByAgent?: boolean; +}>; + +export type AgentListenerInput = { + id: string; + kind: "message"; + surface: "slack"; + entrypoint: IngressEntrypoint; + sessionMode?: SessionMode; + enabled?: boolean; + match?: IngressMatchInput; +}; + +export type AgentListener = Readonly<{ + id: string; + kind: "message"; + surface: "slack"; + entrypoint: IngressEntrypoint; + sessionMode: SessionMode; + enabled: boolean; + match?: IngressMatch; +}>; + +type CronTriggerInput = { + id: string; + kind: "cron"; + schedule: string; + prompt: string; + sessionMode?: SessionMode; + delivery?: SlackDeliveryTargetInput; + enabled?: boolean; +}; + +type HeartbeatTriggerInput = { + id: string; + kind: "heartbeat"; + intervalSeconds: number; + prompt: string; + sessionMode?: SessionMode; + delivery?: SlackDeliveryTargetInput; + enabled?: boolean; +}; + +export type AgentProactiveTriggerInput = CronTriggerInput | HeartbeatTriggerInput; + +type CronTrigger = Readonly<{ + id: string; + kind: "cron"; + schedule: string; + prompt: string; + sessionMode?: SessionMode; + delivery?: SlackDeliveryTarget; + enabled: boolean; +}>; + +type HeartbeatTrigger = Readonly<{ + id: string; + kind: "heartbeat"; + intervalSeconds: number; + prompt: string; + sessionMode?: SessionMode; + delivery?: SlackDeliveryTarget; + enabled: boolean; +}>; + +export type AgentProactiveTrigger = CronTrigger | HeartbeatTrigger; + +export type AgentProactiveInput = { + deliveryDefaults?: SlackDeliveryTargetInput; + triggers: readonly AgentProactiveTriggerInput[]; +}; + +export type AgentProactive = Readonly<{ + deliveryDefaults?: SlackDeliveryTarget; + triggers: readonly AgentProactiveTrigger[]; +}>; + +export type DuckdbResourceInput = { + id: string; + kind: "duckdb"; + path: string; +}; + +export type KnowledgeDocsResourceInput = { + id: string; + kind: "knowledge-docs"; +}; + +export type AgentResourceInput = DuckdbResourceInput | KnowledgeDocsResourceInput; + +export type DuckdbResource = Readonly<{ + id: string; + kind: "duckdb"; + path: string; +}>; + +export type KnowledgeDocsResource = Readonly<{ + id: string; + kind: "knowledge-docs"; +}>; + +export type AgentResource = DuckdbResource | KnowledgeDocsResource; +export type AgentResourceKind = ResourceKind; + +type CapabilitySlotMap = + (typeof capabilityCatalog)[TCapabilityId]["resourceSlots"]; + +type CapabilitySlotId = + keyof CapabilitySlotMap & string; + +type CapabilityBindShape< + TCapabilityId extends CapabilityId, + TResourceId extends string, +> = CapabilitySlotId extends never + ? Readonly<{ + bindResources?: Readonly>; + }> + : Readonly<{ + bindResources: Readonly, TResourceId>>; + }>; + +type CapabilityBindingFor< + TCapabilityId extends CapabilityId, + TResourceId extends string, +> = Readonly<{ + capability: TCapabilityId; +}> & + CapabilityBindShape; + +export type AgentCapabilityBindingInput = { + [TCapabilityId in CapabilityId]: CapabilityBindingFor< + TCapabilityId, + TResourceId + >; +}[CapabilityId]; + +export type AgentCapabilityBinding = Readonly<{ + capability: CapabilityId; + bindResources: Readonly>; +}>; + +type NonEmptyArray = readonly [T, ...T[]]; + +type ResourceIdUnion = + TResources[number]["id"]; + +type ResourceKindForId< + TResources extends readonly AgentResourceInput[], + TResourceId extends ResourceIdUnion, +> = Extract["kind"]; + +type NormalizeResources< + TResources extends readonly AgentResourceInput[] | undefined, +> = TResources extends readonly AgentResourceInput[] ? TResources : readonly []; + +type CapabilityBindingKindErrors< + TResources extends readonly AgentResourceInput[], + TBinding extends AgentCapabilityBindingInput>, +> = CapabilitySlotId extends never + ? never + : { + [Slot in CapabilitySlotId]: TBinding extends { + bindResources: Record; + } + ? TResourceId extends ResourceIdUnion + ? ResourceKindForId extends CapabilitySlotMap< + TBinding["capability"] + >[Slot] + ? never + : `Capability "${TBinding["capability"]}" slot "${Slot}" requires resource kind "${CapabilitySlotMap< + TBinding["capability"] + >[Slot] & string}"` + : never + : `Capability "${TBinding["capability"]}" missing required binding for slot "${Slot}"`; + }[CapabilitySlotId]; + +type CapabilityBindingErrors< + TResources extends readonly AgentResourceInput[], + TCapabilities extends readonly AgentCapabilityBindingInput< + ResourceIdUnion + >[], +> = { + [Index in keyof TCapabilities]: TCapabilities[Index] extends AgentCapabilityBindingInput< + ResourceIdUnion + > + ? CapabilityBindingKindErrors + : never; +}[number]; + +type CompileTimeCapabilityBindingGuard< + TResources extends readonly AgentResourceInput[], + TCapabilities extends readonly AgentCapabilityBindingInput< + ResourceIdUnion + >[], +> = CapabilityBindingErrors extends never + ? {} + : { + __compileTimeCapabilityBindingError__: CapabilityBindingErrors< + TResources, + TCapabilities + >; + }; + +export type AgentDefinitionInput< + TResources extends readonly AgentResourceInput[] | undefined = undefined, + TCapabilities extends NonEmptyArray< + AgentCapabilityBindingInput>> + > = NonEmptyArray< + AgentCapabilityBindingInput>> + >, +> = { + id: string; + name: string; + listen: NonEmptyArray; + useCapabilities: TCapabilities; + description?: string; + model?: string; + proactive?: AgentProactiveInput; + resources?: TResources; + runtime?: AgentRuntime; + quietHours?: QuietHoursInput; + session?: { + defaultMode?: SessionMode; + }; +}; + +export type AgentDefinition = Readonly<{ + id: string; + name: string; + listen: readonly AgentListener[]; + useCapabilities: readonly AgentCapabilityBinding[]; + description?: string; + model?: string; + proactive?: AgentProactive; + resources?: readonly AgentResource[]; + runtime?: AgentRuntime; + quietHours?: QuietHours; + session?: Readonly<{ + defaultMode?: SessionMode; + }>; +}>; + +export type { CapabilityId, CapabilitySkillId }; + +function deepFreeze(value: T): T { + if (Array.isArray(value)) { + for (const item of value) { + deepFreeze(item); + } + return Object.freeze(value); + } + + if (value && typeof value === "object") { + for (const nested of Object.values(value as Record)) { + deepFreeze(nested); + } + return Object.freeze(value); + } + + return value; +} + +function normalizeRequiredString(value: string, label: string): string { + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${label} must be a non-empty string`); + } + return trimmed; +} + +function normalizeOptionalString(value: string | undefined): string | undefined { + if (value === undefined) { + return undefined; + } + return normalizeRequiredString(value, "optional string"); +} + +function normalizeSessionMode(value: SessionMode, label: string): SessionMode { + if (value === "thread" || value === "main" || value === "isolated") { + return value; + } + throw new Error(`${label} must be one of: thread, main, isolated`); +} + +function defaultSessionModeForEntrypoint(entrypoint: IngressEntrypoint): SessionMode { + return entrypoint === "direct_message" ? "main" : "thread"; +} + +function normalizeIngressMatch( + match: IngressMatchInput | undefined, + entrypoint: IngressEntrypoint, + label: string, +): IngressMatch | undefined { + if (!match) { + if (entrypoint === "slash_command") { + throw new Error(`${label} requires match.command`); + } + return undefined; + } + + const normalized: IngressMatchInput = {}; + if (match.command !== undefined) { + const command = normalizeRequiredString(match.command, `${label}.command`) + .toLowerCase() + .replaceAll(/\s+/g, ""); + if (!command.startsWith("/")) { + throw new Error(`${label}.command must start with "/"`); + } + normalized.command = command; + } + if (match.channelId !== undefined) { + normalized.channelId = normalizeRequiredString( + match.channelId, + `${label}.channelId`, + ); + } + if (match.userId !== undefined) { + normalized.userId = normalizeRequiredString(match.userId, `${label}.userId`); + } + if (match.isDirectMessage !== undefined) { + normalized.isDirectMessage = Boolean(match.isDirectMessage); + } + if (match.threadOwnedByAgent !== undefined) { + normalized.threadOwnedByAgent = Boolean(match.threadOwnedByAgent); + } + + if (entrypoint === "slash_command" && !normalized.command) { + throw new Error(`${label} requires match.command`); + } + + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function normalizeDeliveryTarget( + delivery: SlackDeliveryTargetInput, + label: string, +): SlackDeliveryTarget { + if (delivery.mode === "channel_thread") { + return { + surface: "slack", + mode: "channel_thread", + channelId: normalizeRequiredString(delivery.channelId, `${label}.channelId`), + }; + } + + return { + surface: "slack", + mode: "dm", + userId: normalizeRequiredString(delivery.userId, `${label}.userId`), + }; +} + +function normalizeQuietHours(value: QuietHoursInput, label: string): QuietHours { + const startHour = Math.floor(value.startHour); + const endHour = Math.floor(value.endHour); + + if (startHour < 0 || startHour > 23 || endHour < 0 || endHour > 23) { + throw new Error(`${label} startHour/endHour must be in [0, 23]`); + } + + const daysOfWeek = value.daysOfWeek + ? Array.from(new Set(value.daysOfWeek.map((day) => Math.floor(day)))) + : undefined; + + if ( + daysOfWeek && + daysOfWeek.some((day) => !Number.isInteger(day) || day < 0 || day > 6) + ) { + throw new Error(`${label}.daysOfWeek values must be in [0, 6]`); + } + + return { + enabled: value.enabled ?? true, + timezone: normalizeRequiredString(value.timezone, `${label}.timezone`), + startHour, + endHour, + ...(daysOfWeek ? { daysOfWeek } : {}), + }; +} + +function normalizeResource( + resource: AgentResourceInput, + label: string, +): AgentResource { + if (resource.kind === "duckdb") { + return { + id: normalizeRequiredString(resource.id, `${label}.id`), + kind: "duckdb", + path: normalizeRequiredString(resource.path, `${label}.path`), + }; + } + + if (resource.kind === "knowledge-docs") { + return { + id: normalizeRequiredString(resource.id, `${label}.id`), + kind: "knowledge-docs", + }; + } + + throw new Error(`${label}.kind must be one of: duckdb, knowledge-docs`); +} + +function normalizeResources( + values: readonly AgentResourceInput[] | undefined, + label: string, +): readonly AgentResource[] | undefined { + if (!values) { + return undefined; + } + + const normalized: AgentResource[] = []; + const seenIds = new Set(); + + for (const [index, resource] of values.entries()) { + const entryLabel = `${label}[${index}]`; + const normalizedResource = normalizeResource(resource, entryLabel); + + if (seenIds.has(normalizedResource.id)) { + throw new Error( + `${entryLabel} duplicates resource id "${normalizedResource.id}"`, + ); + } + + seenIds.add(normalizedResource.id); + normalized.push(normalizedResource); + } + + return normalized.length > 0 ? normalized : undefined; +} + +function normalizeCapabilityBinding( + inputBinding: AgentCapabilityBindingInput, + label: string, + resourceKindsById: ReadonlyMap, +): AgentCapabilityBinding { + const capabilityId = inputBinding.capability; + const definition = capabilityCatalog[capabilityId]; + if (!definition) { + const validCapabilities = Object.keys(capabilityCatalog).sort().join(", "); + throw new Error(`${label}.capability must be one of: ${validCapabilities}`); + } + + const bindInput: Readonly> = + "bindResources" in inputBinding && inputBinding.bindResources + ? inputBinding.bindResources + : {}; + const expectedSlotEntries = Object.entries(definition.resourceSlots).sort((a, b) => + a[0].localeCompare(b[0]), + ); + + const normalizedBind: Record = {}; + + for (const [slot, expectedKind] of expectedSlotEntries) { + const rawResourceId = bindInput[slot]; + if (rawResourceId === undefined) { + throw new Error( + `${label}.bindResources is missing required slot "${slot}" for capability "${capabilityId}"`, + ); + } + + const resourceId = normalizeRequiredString( + rawResourceId, + `${label}.bindResources.${slot}`, + ); + const actualKind = resourceKindsById.get(resourceId); + if (!actualKind) { + throw new Error( + `${label}.bindResources.${slot} references unknown resource id "${resourceId}"`, + ); + } + if (actualKind !== expectedKind) { + throw new Error( + `${label}.bindResources.${slot} requires resource kind "${expectedKind}" but received "${actualKind}"`, + ); + } + + normalizedBind[slot] = resourceId; + } + + const expectedSlotSet = new Set(expectedSlotEntries.map(([slot]) => slot)); + for (const slot of Object.keys(bindInput)) { + if (!expectedSlotSet.has(slot)) { + throw new Error( + `${label}.bindResources.${slot} is not a valid slot for capability "${capabilityId}"`, + ); + } + } + + return { + capability: capabilityId, + bindResources: normalizedBind, + }; +} + +function normalizeCapabilities( + values: readonly AgentCapabilityBindingInput[], + label: string, + resources: readonly AgentResource[] | undefined, +): readonly AgentCapabilityBinding[] { + if (values.length === 0) { + throw new Error("agent must declare at least one capability"); + } + + const resourceKindsById = new Map(); + for (const resource of resources ?? []) { + resourceKindsById.set(resource.id, resource.kind); + } + + const normalized: AgentCapabilityBinding[] = []; + const seenCapabilitySignatures = new Set(); + + for (const [index, capability] of values.entries()) { + const entryLabel = `${label}[${index}]`; + const normalizedBinding = normalizeCapabilityBinding( + capability, + entryLabel, + resourceKindsById, + ); + const signature = [ + normalizedBinding.capability, + ...Object.entries(normalizedBinding.bindResources) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([slot, resourceId]) => `${slot}=${resourceId}`), + ].join("|"); + + if (seenCapabilitySignatures.has(signature)) { + throw new Error(`${entryLabel} duplicates capability binding "${signature}"`); + } + + seenCapabilitySignatures.add(signature); + normalized.push(normalizedBinding); + } + + return normalized; +} + +function normalizeProactiveTrigger( + trigger: AgentProactiveTriggerInput, + label: string, +): AgentProactiveTrigger { + const id = normalizeRequiredString(trigger.id, `${label}.id`); + const prompt = normalizeRequiredString(trigger.prompt, `${label}.prompt`); + const sessionMode = + trigger.sessionMode !== undefined + ? normalizeSessionMode(trigger.sessionMode, `${label}.sessionMode`) + : undefined; + const enabled = trigger.enabled ?? true; + const delivery = trigger.delivery + ? normalizeDeliveryTarget(trigger.delivery, `${label}.delivery`) + : undefined; + + if (trigger.kind === "cron") { + return { + id, + kind: "cron", + schedule: normalizeRequiredString(trigger.schedule, `${label}.schedule`), + prompt, + enabled, + ...(sessionMode ? { sessionMode } : {}), + ...(delivery ? { delivery } : {}), + }; + } + + const intervalSeconds = Math.floor(trigger.intervalSeconds); + if (!Number.isFinite(intervalSeconds) || intervalSeconds < 5) { + throw new Error(`${label}.intervalSeconds must be a number >= 5`); + } + + return { + id, + kind: "heartbeat", + intervalSeconds, + prompt, + enabled, + ...(sessionMode ? { sessionMode } : {}), + ...(delivery ? { delivery } : {}), + }; +} + +export function defineConfig(input: FrameworkConfigInput): FrameworkConfig { + const config: FrameworkConfig = { + infra: { + database: { + urlEnvVar: normalizeRequiredString( + input.infra.database.urlEnvVar, + "config.infra.database.urlEnvVar", + ), + }, + slack: { + appTokenEnvVar: normalizeRequiredString( + input.infra.slack.appTokenEnvVar, + "config.infra.slack.appTokenEnvVar", + ), + botTokenEnvVar: normalizeRequiredString( + input.infra.slack.botTokenEnvVar, + "config.infra.slack.botTokenEnvVar", + ), + }, + modelProvider: { + provider: input.infra.modelProvider.provider, + apiKeyEnvVar: normalizeRequiredString( + input.infra.modelProvider.apiKeyEnvVar, + "config.infra.modelProvider.apiKeyEnvVar", + ), + }, + }, + defaults: { + model: normalizeRequiredString(input.defaults.model, "config.defaults.model"), + runtime: input.defaults.runtime, + sessionMode: normalizeSessionMode( + input.defaults.sessionMode, + "config.defaults.sessionMode", + ), + ...(input.defaults.quietHours + ? { + quietHours: normalizeQuietHours( + input.defaults.quietHours, + "config.defaults.quietHours", + ), + } + : {}), + }, + paths: { + sharedRoot: normalizeRequiredString( + input.paths.sharedRoot, + "config.paths.sharedRoot", + ), + workspaceRoot: normalizeRequiredString( + input.paths.workspaceRoot, + "config.paths.workspaceRoot", + ), + }, + }; + + return deepFreeze(config); +} + +export function defineAgent< + const TResources extends readonly AgentResourceInput[] | undefined, + const TCapabilities extends NonEmptyArray< + AgentCapabilityBindingInput>> + >, +>( + input: AgentDefinitionInput & + CompileTimeCapabilityBindingGuard, TCapabilities>, +): AgentDefinition { + if ( + Object.prototype.hasOwnProperty.call( + input as Record, + "duckdbPath", + ) + ) { + throw new Error( + 'agent.duckdbPath has been removed; use resources: [{ id: "warehouse", kind: "duckdb", path: "" }]', + ); + } + + if ( + Object.prototype.hasOwnProperty.call( + input as Record, + "connectors", + ) + ) { + throw new Error( + 'agent.connectors has been renamed to resources: [{ id: "", kind: "duckdb" | "knowledge-docs", ... }]', + ); + } + + if ( + Object.prototype.hasOwnProperty.call( + input as Record, + "capabilities", + ) + ) { + throw new Error( + 'agent.capabilities has been renamed to useCapabilities: [{ capability: "", bindResources?: { "": "" } }]', + ); + } + + if ( + Object.prototype.hasOwnProperty.call( + input as Record, + "tools", + ) + ) { + throw new Error( + 'agent.tools has been replaced by useCapabilities: [{ capability: "", bindResources?: { "": "" } }]', + ); + } + + if ( + Object.prototype.hasOwnProperty.call( + input as Record, + "skills", + ) + ) { + throw new Error( + 'agent.skills has been replaced by useCapabilities: [{ capability: "", bindResources?: { "": "" } }]', + ); + } + + const id = normalizeRequiredString(input.id, "agent.id"); + const name = normalizeRequiredString(input.name, "agent.name"); + const description = normalizeOptionalString(input.description); + const model = normalizeOptionalString(input.model); + const runtime = input.runtime; + const quietHours = input.quietHours + ? normalizeQuietHours(input.quietHours, `agent(${id}).quietHours`) + : undefined; + const resources = normalizeResources(input.resources, `agent(${id}).resources`); + const useCapabilities = normalizeCapabilities( + input.useCapabilities, + `agent(${id}).useCapabilities`, + resources, + ); + + const listeners = input.listen.map((listener, index) => { + const listenerLabel = `agent(${id}).listen[${index}]`; + const listenerId = normalizeRequiredString(listener.id, `${listenerLabel}.id`); + const sessionMode = listener.sessionMode + ? normalizeSessionMode(listener.sessionMode, `${listenerLabel}.sessionMode`) + : defaultSessionModeForEntrypoint(listener.entrypoint); + const match = normalizeIngressMatch( + listener.match, + listener.entrypoint, + `${listenerLabel}.match`, + ); + + return { + id: listenerId, + kind: listener.kind, + surface: listener.surface, + entrypoint: listener.entrypoint, + sessionMode, + enabled: listener.enabled ?? true, + ...(match ? { match } : {}), + } satisfies AgentListener; + }); + if (listeners.length === 0) { + throw new Error(`agent(${id}) must declare at least one listener`); + } + + const proactive = input.proactive + ? { + ...(input.proactive.deliveryDefaults + ? { + deliveryDefaults: normalizeDeliveryTarget( + input.proactive.deliveryDefaults, + `agent(${id}).proactive.deliveryDefaults`, + ), + } + : {}), + triggers: input.proactive.triggers.map((trigger, index) => + normalizeProactiveTrigger(trigger, `agent(${id}).proactive.triggers[${index}]`), + ), + } + : undefined; + + const declaration: AgentDefinition = { + id, + name, + listen: listeners, + useCapabilities, + ...(description ? { description } : {}), + ...(model ? { model } : {}), + ...(proactive ? { proactive } : {}), + ...(resources ? { resources } : {}), + ...(runtime ? { runtime } : {}), + ...(quietHours ? { quietHours } : {}), + ...(input.session + ? { + session: { + ...(input.session.defaultMode + ? { + defaultMode: normalizeSessionMode( + input.session.defaultMode, + `agent(${id}).session.defaultMode`, + ), + } + : {}), + }, + } + : {}), + }; + + return deepFreeze(declaration); +} + +export function resolveAgentModel( + agent: AgentDefinition, + config: FrameworkConfig, +): string { + return agent.model ?? config.defaults.model; +} + +export function resolveAgentRuntime( + agent: AgentDefinition, + config: FrameworkConfig, +): AgentRuntime { + return agent.runtime ?? config.defaults.runtime; +} + +export function resolveAgentSessionMode( + agent: AgentDefinition, + config: FrameworkConfig, +): SessionMode { + return agent.session?.defaultMode ?? config.defaults.sessionMode; +} + +export function resolveAgentQuietHours( + agent: AgentDefinition, + config: FrameworkConfig, +): QuietHours | undefined { + return agent.quietHours ?? config.defaults.quietHours; +} diff --git a/agents/contracts.typecheck.ts b/agents/contracts.typecheck.ts new file mode 100644 index 0000000..08647d4 --- /dev/null +++ b/agents/contracts.typecheck.ts @@ -0,0 +1,89 @@ +import { defineAgent } from "./contracts.js"; + +const baseListen = [ + { + id: "typecheck-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/typecheck", + }, + }, +] as const; + +void defineAgent({ + id: "typecheck-valid", + name: "Typecheck Valid", + listen: baseListen, + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + ], +}); + +void defineAgent({ + id: "typecheck-missing-resource-id", + name: "Typecheck Missing Resource ID", + listen: baseListen, + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + ], + // @ts-expect-error bindResources slot must reference a declared resource id + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "missing", + }, + }, + ], +}); + +// @ts-expect-error duckdb-analyst-v1 requires warehouse slot bound to a duckdb resource +void defineAgent({ + id: "typecheck-kind-mismatch", + name: "Typecheck Kind Mismatch", + listen: baseListen, + resources: [ + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "policy-docs", + }, + }, + ], +}); diff --git a/agents/data-analyst/agent.ts b/agents/data-analyst/agent.ts new file mode 100644 index 0000000..11e8ede --- /dev/null +++ b/agents/data-analyst/agent.ts @@ -0,0 +1,108 @@ +import { defineAgent } from "../contracts.js"; + +export const dataAnalystAgent = defineAgent({ + id: "data-analyst", + name: "Wiggs", + description: "Data analyst proof-of-concept agent.", + model: "claude-sonnet-4-5-20250929", + runtime: "host", + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb", + }, + ], + session: { + defaultMode: "thread", + }, + listen: [ + { + id: "slack-wiggs-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + sessionMode: "thread", + match: { + command: "/wiggs", + }, + enabled: true, + }, + { + id: "slack-wiggs-mention", + kind: "message", + surface: "slack", + entrypoint: "app_mention", + sessionMode: "thread", + enabled: true, + }, + { + id: "slack-wiggs-thread", + kind: "message", + surface: "slack", + entrypoint: "thread_reply", + match: { + threadOwnedByAgent: true, + }, + sessionMode: "thread", + enabled: true, + }, + { + id: "slack-wiggs-dm", + kind: "message", + surface: "slack", + entrypoint: "direct_message", + sessionMode: "main", + enabled: true, + }, + ], + proactive: { + deliveryDefaults: { + surface: "slack", + mode: "channel_thread", + channelId: "C0AFKMMDV4J", + }, + triggers: [ + { + id: "daily-metrics", + kind: "cron", + schedule: "0 9 * * *", + sessionMode: "isolated", + prompt: "Run the daily metrics check and summarize notable changes.", + delivery: { + surface: "slack", + mode: "channel_thread", + channelId: "C0AFKMMDV4J", + }, + enabled: false, + }, + { + id: "founder-heartbeat", + kind: "heartbeat", + intervalSeconds: 1800, + sessionMode: "main", + prompt: "Check for anomalies and notify if action is needed.", + delivery: { + surface: "slack", + mode: "dm", + userId: "U123456", + }, + enabled: false, + }, + ], + }, + useCapabilities: [ + { + capability: "query-gravity-v1", + }, + { + capability: "rollback-v1", + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + ], +}); diff --git a/agents/index.ts b/agents/index.ts new file mode 100644 index 0000000..b6bcb95 --- /dev/null +++ b/agents/index.ts @@ -0,0 +1,495 @@ +import { + compileAgentCapabilities, + type CompiledAgentCapabilities, +} from "./capability-compiler.js"; +import { + defineConfig, + resolveAgentModel, + resolveAgentQuietHours, + resolveAgentRuntime, + resolveAgentSessionMode, + type AgentDefinition, + type FrameworkConfig, + type IngressEntrypoint, + type IngressMatch, + type QuietHours, + type SessionMode, + type SlackDeliveryTarget, +} from "./contracts.js"; +import { complianceHelperAgent } from "./compliance-helper/agent.js"; +import { dataAnalystAgent } from "./data-analyst/agent.js"; + +export type RunIdPattern = "slack:{sourceEventId}" | "{sourceEventId}"; +export type SessionKeyPattern = + | "{agentId}:main" + | "{agentId}:{sourceEventId}" + | "{agentId}:{threadTs}" + | "{agentId}:{channelId}" + | "{agentId}:proactive:{triggerId}:thread" + | "{agentId}:proactive:{triggerId}:{sourceEventId}"; + +export type CompiledMessageEntrypoint = Exclude; + +type MessageTriggerDimensions = Readonly<{ + triggerKind: "message"; + surface: "slack"; + entrypoint: IngressEntrypoint; + runIdPattern: "slack:{sourceEventId}"; +}>; + +type ProactiveTriggerDimensions = Readonly<{ + triggerKind: "cron" | "heartbeat"; + surface: "system"; + entrypoint: "cron" | "heartbeat"; + runIdPattern: "{sourceEventId}"; +}>; + +export type CompiledTriggerDimensions = + | MessageTriggerDimensions + | ProactiveTriggerDimensions; + +type CompiledIngressListenerBase = Readonly<{ + agentId: string; + listenerId: string; + entrypoint: IngressEntrypoint; + sessionMode: SessionMode; + match?: IngressMatch; + trigger: MessageTriggerDimensions; +}>; + +export type CompiledSlashCommandListener = Readonly<{ + command: string; +}> & + CompiledIngressListenerBase & + Readonly<{ + entrypoint: "slash_command"; + }>; + +export type CompiledMessageListener = CompiledIngressListenerBase & + Readonly<{ + entrypoint: CompiledMessageEntrypoint; + }>; + +export type CompiledIngressListener = + | CompiledSlashCommandListener + | CompiledMessageListener; + +type CompiledProactiveTriggerBase = Readonly<{ + agentId: string; + triggerId: string; + prompt: string; + sessionMode: SessionMode; + delivery: SlackDeliveryTarget; + quietHours?: QuietHours; + trigger: ProactiveTriggerDimensions; +}>; + +export type CompiledProactiveTrigger = + | (CompiledProactiveTriggerBase & + Readonly<{ + kind: "cron"; + schedule: string; + }>) + | (CompiledProactiveTriggerBase & + Readonly<{ + kind: "heartbeat"; + intervalSeconds: number; + }>); + +export type CompiledSessionDimension = Readonly<{ + agentId: string; + sourceKind: "ingress" | "proactive"; + sourceId: string; + sessionMode: SessionMode; + sessionKeyPatterns: readonly SessionKeyPattern[]; + trigger: CompiledTriggerDimensions; +}>; + +export type CompiledAgentDeclarations = Readonly<{ + ingress: Readonly<{ + listeners: readonly CompiledIngressListener[]; + slashCommands: Readonly>; + messageByEntrypoint: Readonly< + Record + >; + }>; + proactive: Readonly<{ + triggers: readonly CompiledProactiveTrigger[]; + byAgentId: Readonly>; + }>; + sessions: Readonly<{ + dimensions: readonly CompiledSessionDimension[]; + byAgentId: Readonly>; + }>; + triggerDimensions: readonly CompiledTriggerDimensions[]; +}>; + +export type RegisteredAgent = Readonly<{ + agentId: string; + declaration: AgentDefinition; + compiledCapabilities: CompiledAgentCapabilities; + model: string; + runtime: AgentDefinition["runtime"] | FrameworkConfig["defaults"]["runtime"]; + defaultSessionMode: SessionMode; + quietHours?: QuietHours; +}>; + +export type AgentRegistry = Readonly<{ + config: FrameworkConfig; + agents: readonly AgentDefinition[]; + agentsById: ReadonlyMap; + slashCommandListeners: ReadonlyMap; + compiledDeclarations: CompiledAgentDeclarations; +}>; + +function createRegisteredAgent( + declaration: AgentDefinition, + config: FrameworkConfig, +): RegisteredAgent { + const resources = declaration.resources ?? []; + const compiledCapabilities = compileAgentCapabilities({ + resources, + useCapabilities: declaration.useCapabilities, + }); + const quietHours = resolveAgentQuietHours(declaration, config); + const effectiveQuietHours = + quietHours && quietHours.enabled === false ? undefined : quietHours; + return { + agentId: declaration.id, + declaration, + compiledCapabilities, + model: resolveAgentModel(declaration, config), + runtime: resolveAgentRuntime(declaration, config), + defaultSessionMode: resolveAgentSessionMode(declaration, config), + ...(effectiveQuietHours ? { quietHours: effectiveQuietHours } : {}), + }; +} + +function normalizeSlashCommand(command: string): string { + return command.trim().toLowerCase(); +} + +function normalizeProactiveSessionMode( + requested: SessionMode | undefined, + delivery: SlackDeliveryTarget, +): SessionMode { + const sessionMode = requested ?? "isolated"; + if (sessionMode === "thread" && delivery.mode === "dm") { + return "main"; + } + return sessionMode; +} + +function ingressSessionKeyPatterns( + sessionMode: SessionMode, +): readonly SessionKeyPattern[] { + if (sessionMode === "main") { + return ["{agentId}:main"]; + } + if (sessionMode === "isolated") { + return ["{agentId}:{sourceEventId}"]; + } + return ["{agentId}:{threadTs}", "{agentId}:{channelId}"]; +} + +function proactiveSessionKeyPatterns( + sessionMode: SessionMode, +): readonly SessionKeyPattern[] { + if (sessionMode === "main") { + return ["{agentId}:main"]; + } + if (sessionMode === "thread") { + return ["{agentId}:proactive:{triggerId}:thread"]; + } + return ["{agentId}:proactive:{triggerId}:{sourceEventId}"]; +} + +function pushByAgent( + index: Record, + agentId: string, + value: T, +): void { + const current = index[agentId]; + if (current) { + current.push(value); + return; + } + index[agentId] = [value]; +} + +export function createAgentRegistry(input: { + config: FrameworkConfig; + agents: readonly AgentDefinition[]; +}): AgentRegistry { + const agentsById = new Map(); + const slashCommandListeners = new Map(); + const compiledSlashCommands: Record = {}; + const listenerIds = new Set(); + const ingressListeners: CompiledIngressListener[] = []; + const messageByEntrypoint: Record< + CompiledMessageEntrypoint, + CompiledMessageListener[] + > = { + app_mention: [], + thread_reply: [], + direct_message: [], + }; + const proactiveTriggers: CompiledProactiveTrigger[] = []; + const proactiveByAgentId: Record = {}; + const sessionDimensions: CompiledSessionDimension[] = []; + const sessionByAgentId: Record = {}; + const triggerDimensions: CompiledTriggerDimensions[] = []; + + for (const declaration of input.agents) { + if (agentsById.has(declaration.id)) { + throw new Error(`duplicate agent id declared: ${declaration.id}`); + } + + const registered = createRegisteredAgent(declaration, input.config); + agentsById.set(declaration.id, registered); + const quietHours = registered.quietHours; + + for (const listener of declaration.listen) { + const ownershipKey = `${declaration.id}:${listener.id}`; + if (listenerIds.has(ownershipKey)) { + throw new Error(`duplicate listener id declared: ${ownershipKey}`); + } + listenerIds.add(ownershipKey); + + if (listener.enabled === false) { + continue; + } + + if (listener.entrypoint !== "slash_command") { + + const trigger: MessageTriggerDimensions = { + triggerKind: "message", + surface: "slack", + entrypoint: listener.entrypoint, + runIdPattern: "slack:{sourceEventId}", + }; + triggerDimensions.push(trigger); + + const compiledMessageListener: CompiledMessageListener = { + agentId: declaration.id, + listenerId: listener.id, + entrypoint: listener.entrypoint, + sessionMode: listener.sessionMode, + ...(listener.match ? { match: listener.match } : {}), + trigger, + }; + + ingressListeners.push(compiledMessageListener); + messageByEntrypoint[listener.entrypoint].push(compiledMessageListener); + + const sessionDimension: CompiledSessionDimension = { + agentId: declaration.id, + sourceKind: "ingress", + sourceId: listener.id, + sessionMode: listener.sessionMode, + sessionKeyPatterns: ingressSessionKeyPatterns(listener.sessionMode), + trigger, + }; + sessionDimensions.push(sessionDimension); + pushByAgent(sessionByAgentId, declaration.id, sessionDimension); + continue; + } + + const rawCommand = listener.match?.command; + if (!rawCommand) { + throw new Error(`slash listener ${ownershipKey} is missing match.command`); + } + + const command = normalizeSlashCommand(rawCommand); + const existing = slashCommandListeners.get(command); + if (existing) { + throw new Error( + `slash command collision for ${command}: ${existing.agentId}:${existing.listenerId} and ${ownershipKey}`, + ); + } + + const trigger: MessageTriggerDimensions = { + triggerKind: "message", + surface: "slack", + entrypoint: "slash_command", + runIdPattern: "slack:{sourceEventId}", + }; + triggerDimensions.push(trigger); + + const compiledSlashListener: CompiledSlashCommandListener = { + agentId: declaration.id, + listenerId: listener.id, + command, + sessionMode: listener.sessionMode, + entrypoint: "slash_command", + ...(listener.match ? { match: listener.match } : {}), + trigger, + }; + + slashCommandListeners.set(command, compiledSlashListener); + compiledSlashCommands[command] = compiledSlashListener; + ingressListeners.push(compiledSlashListener); + + const sessionDimension: CompiledSessionDimension = { + agentId: declaration.id, + sourceKind: "ingress", + sourceId: listener.id, + sessionMode: listener.sessionMode, + sessionKeyPatterns: ingressSessionKeyPatterns(listener.sessionMode), + trigger, + }; + sessionDimensions.push(sessionDimension); + pushByAgent(sessionByAgentId, declaration.id, sessionDimension); + } + + const proactiveTriggerIds = new Set(); + for (const proactiveTrigger of declaration.proactive?.triggers ?? []) { + if (proactiveTrigger.enabled === false) { + continue; + } + + if (proactiveTriggerIds.has(proactiveTrigger.id)) { + throw new Error( + `duplicate proactive trigger id declared: ${declaration.id}:${proactiveTrigger.id}`, + ); + } + proactiveTriggerIds.add(proactiveTrigger.id); + + const delivery = + proactiveTrigger.delivery ?? declaration.proactive?.deliveryDefaults; + if (!delivery) { + throw new Error( + `proactive trigger ${declaration.id}:${proactiveTrigger.id} is missing delivery and proactive deliveryDefaults`, + ); + } + + const sessionMode = normalizeProactiveSessionMode( + proactiveTrigger.sessionMode, + delivery, + ); + const trigger: ProactiveTriggerDimensions = { + triggerKind: proactiveTrigger.kind, + surface: "system", + entrypoint: proactiveTrigger.kind, + runIdPattern: "{sourceEventId}", + }; + triggerDimensions.push(trigger); + + const compiledProactiveTrigger: CompiledProactiveTrigger = + proactiveTrigger.kind === "cron" + ? { + agentId: declaration.id, + triggerId: proactiveTrigger.id, + kind: "cron", + schedule: proactiveTrigger.schedule, + prompt: proactiveTrigger.prompt, + sessionMode, + delivery, + ...(quietHours ? { quietHours } : {}), + trigger, + } + : { + agentId: declaration.id, + triggerId: proactiveTrigger.id, + kind: "heartbeat", + intervalSeconds: proactiveTrigger.intervalSeconds, + prompt: proactiveTrigger.prompt, + sessionMode, + delivery, + ...(quietHours ? { quietHours } : {}), + trigger, + }; + + proactiveTriggers.push(compiledProactiveTrigger); + pushByAgent(proactiveByAgentId, declaration.id, compiledProactiveTrigger); + + const sessionDimension: CompiledSessionDimension = { + agentId: declaration.id, + sourceKind: "proactive", + sourceId: proactiveTrigger.id, + sessionMode, + sessionKeyPatterns: proactiveSessionKeyPatterns(sessionMode), + trigger, + }; + sessionDimensions.push(sessionDimension); + pushByAgent(sessionByAgentId, declaration.id, sessionDimension); + } + } + + const frozenMessageByEntrypoint = Object.freeze({ + app_mention: Object.freeze([...messageByEntrypoint.app_mention]), + thread_reply: Object.freeze([...messageByEntrypoint.thread_reply]), + direct_message: Object.freeze([...messageByEntrypoint.direct_message]), + }); + + const frozenProactiveByAgentId: Record = + {}; + for (const [agentId, triggers] of Object.entries(proactiveByAgentId)) { + frozenProactiveByAgentId[agentId] = Object.freeze([...triggers]); + } + + const frozenSessionByAgentId: Record = + {}; + for (const [agentId, dimensions] of Object.entries(sessionByAgentId)) { + frozenSessionByAgentId[agentId] = Object.freeze([...dimensions]); + } + + const compiledDeclarations: CompiledAgentDeclarations = Object.freeze({ + ingress: Object.freeze({ + listeners: Object.freeze([...ingressListeners]), + slashCommands: Object.freeze({ ...compiledSlashCommands }), + messageByEntrypoint: frozenMessageByEntrypoint, + }), + proactive: Object.freeze({ + triggers: Object.freeze([...proactiveTriggers]), + byAgentId: Object.freeze(frozenProactiveByAgentId), + }), + sessions: Object.freeze({ + dimensions: Object.freeze([...sessionDimensions]), + byAgentId: Object.freeze(frozenSessionByAgentId), + }), + triggerDimensions: Object.freeze([...triggerDimensions]), + }); + + return Object.freeze({ + config: input.config, + agents: Object.freeze([...input.agents]), + agentsById, + slashCommandListeners, + compiledDeclarations, + }); +} + +export const runtimeConfig = defineConfig({ + infra: { + database: { + urlEnvVar: "DATABASE_URL", + }, + slack: { + appTokenEnvVar: "SLACK_APP_TOKEN", + botTokenEnvVar: "SLACK_BOT_TOKEN", + }, + modelProvider: { + provider: "anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + }, + }, + defaults: { + model: "claude-sonnet-4-5-20250929", + runtime: "host", + sessionMode: "thread", + }, + paths: { + sharedRoot: "store/shared", + workspaceRoot: "workspace", + }, +}); + +export const agentDeclarations = [dataAnalystAgent, complianceHelperAgent] as const; + +export const agentRegistry = createAgentRegistry({ + config: runtimeConfig, + agents: agentDeclarations, +}); + +export const compiledDeclarations = agentRegistry.compiledDeclarations; diff --git a/agents/resource-kinds.ts b/agents/resource-kinds.ts new file mode 100644 index 0000000..ee46767 --- /dev/null +++ b/agents/resource-kinds.ts @@ -0,0 +1,3 @@ +export const resourceKinds = ["duckdb", "knowledge-docs"] as const; + +export type ResourceKind = (typeof resourceKinds)[number]; diff --git a/agents/tool-primitives.ts b/agents/tool-primitives.ts new file mode 100644 index 0000000..c33f248 --- /dev/null +++ b/agents/tool-primitives.ts @@ -0,0 +1,3 @@ +export const toolPrimitives = ["read", "bash"] as const; + +export type ToolPrimitive = (typeof toolPrimitives)[number]; diff --git a/docs/PLANS.md b/docs/PLANS.md index fdd5753..fa197fe 100644 --- a/docs/PLANS.md +++ b/docs/PLANS.md @@ -3,10 +3,12 @@ ## Plan Types 1. Lightweight plans for small, single-file changes. 2. Active execution plans for multi-step or risky changes. -3. Completed plans archived for historical context. +3. On-hold plans paused behind an explicit resume gate. +4. Completed plans archived for historical context. ## Source of Truth - Active: `docs/plans/active/` +- On hold: `docs/plans/on-hold/` - Completed: `docs/plans/completed/` - Status board: `docs/checkpoints/mvp-status.md` - Debt inventory: `docs/tech-debt-tracker.md` diff --git a/docs/README.md b/docs/README.md index 622c435..758afa7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,6 +4,7 @@ These docs are the canonical source of truth for architecture, execution status, ## Core Documents +- [Canonical rearchitecture decision](architecture/rearchitecture-decision.md) - [Architecture system map](architecture/system-map.md) - [Architecture interfaces](architecture/interfaces.md) - [MVP checkpoint status](checkpoints/mvp-status.md) diff --git a/docs/architecture/interfaces.md b/docs/architecture/interfaces.md index 4342273..a985400 100644 --- a/docs/architecture/interfaces.md +++ b/docs/architecture/interfaces.md @@ -5,28 +5,40 @@ Keep moving parts explicit and replaceable. ## Runtime Interfaces (current) - `DbClient` (`src/runtime/db.ts`): owns typed Postgres connectivity via Kysely and provides the `gravity` schema handle. - `SlackTransport` (`src/runtime/slack-transport.ts`): owns Slack Socket Mode connection, inbound event normalization, and channel-scoped message queueing. -- `SlashCommandRouter` (`src/runtime/slash-command-router.ts`): current Slack slash-command resolver seam; expected to converge into `IngressBindingResolver` as config-driven ingress matures. +- `defineConfig` / `defineAgent` contracts (`agents/contracts.ts`): canonical code-defined configuration and agent declaration authoring model. +- `AgentRegistry` (`agents/index.ts`): typed registry assembly with duplicate `agentId` and slash-command collision guards. +- `CompiledAgentDeclarations` (`agents/index.ts`): code-defined ingress/proactive/session declarations compiled from `defineConfig` + `defineAgent` contracts for runtime cutover. - `SurfaceAdapter`: surface-specific ingress/egress adapters (Slack now; additional surfaces later). -- `TriggerNormalizer` (`src/runtime/trigger-normalizer.ts`): normalizes source events into trigger dimensions (`triggerKind`, `surface`, `entrypoint`). -- `AgentConfig` (`src/runtime/agent-config.ts`): validates and normalizes agent `config` payloads (`ingressBindings`, `deliveryDefaults`, `proactiveTriggers`, `policy.quietHours`) into typed runtime contracts with strict fail-closed behavior on invalid config. -- `AgentSpecRepository`: loads `gravity.agents` + MVP `config` into a typed `AgentSpec`. -- `IngressBindingResolver`: enforces `ingressBindings` for Slack entrypoints (slash command, app mention, thread reply, direct message). +- `AgentSpecRepository`: transitional repository seam while behavior source moves off `gravity.agents.config`. - `EventIdempotencyGuard` (`src/runtime/event-idempotency.ts`): blocks duplicate source events across slash and non-slash ingress paths. +- `SessionKeyBuilder` (`src/runtime/session-key.ts`): canonical builders for mode-dependent session key patterns across slash, message, and proactive entrypoints. - `SessionResolver`: resolves `sessionKey` and session mode (`thread`, `main`, `isolated`) per trigger. - `SessionCatalog`: stores and resolves session metadata in `gravity.sessions` (ownership, mode, status) while keeping full transcript/context in `workspace/` files. -- `ConnectorRegistry`: resolves connector plugins (for example `duckdb`) and connector-specific context loading. -- `SkillLoader`: loads shared + agent-specific skills from `store/` each turn (no caching). +- `ResourcePlugin` (`src/resources/types.ts`): typed resource interface (`load(...)`) with discriminated resource specs and compile-time contribution contracts. +- `ResourceRegistry` (`src/resources/registry.ts`): statically maps all resource kinds to plugins with exhaustive compile-time coverage checks and resolves per-turn resource contributions. +- `CapabilityCatalog` (`agents/capability-catalog.ts`): canonical catalog of capability definitions (`resourceSlots`, `skills`, and tool grants). +- `CapabilityBindingContract` (`agents/contracts.ts`): typed `useCapabilities[]` + `bindResources` agent contract with compile-time slot/resource-kind checks. +- `CapabilityCompiler` (`agents/capability-compiler.ts`): compiles capability bindings into per-agent runtime capability profile (required skills/resources + tool grants). +- `SkillResolver` (`src/runtime/context-assembler.ts`): resolves capability-derived shared skill IDs to shared skill markdown each turn (no caching). +- `AgentLocalSkillOverlay` (`src/runtime/context-assembler.ts`): loader for `store/agents/{agentId}/skills/*.md` overlays. - `MemoryStore`: loads/writes `MEMORY.md` per agent. -- `ContextAssembler`: builds per-turn system context from agent spec + skills + memory + connector context. +- `ContextAssembler` (`src/runtime/context-assembler.ts`): builds per-turn system context from compiled capability profile + memory + resource contributions. - `TurnRunner` (`PiAgentRunner` for CP4): executes one model turn via `pi-coding-agent` and tool surface. - `DeliveryAdapter`: posts acknowledgements/final responses using surface-specific delivery defaults. - `SessionStore`: manages per-session `log.jsonl` and `context.jsonl` files. - `RunLifecycleLogger` (`src/runtime/run-lifecycle.ts`): emits typed run lifecycle events with stable IDs (`runId`, `agentId`, `sessionKey`) and lifecycle stages (`started`, `completed`, `failed`). - `RunLogStore` (`src/runtime/run-log-store.ts`): maps lifecycle stages into durable `gravity.runs` inserts/updates. -- `ToolDispatcher`: single dispatch seam for all tool execution (host now, sandbox later). -- `ProactiveTriggerResolver` (`src/runtime/proactive-trigger-resolver.ts`): resolves `proactiveTriggers` + `deliveryDefaults` + optional quiet-hours policy into validated cron/heartbeat trigger specs. +- `ExecutorManager` (`src/runtime/executor-manager.ts`): single executor dispatch seam for all tool execution with per-agent runtime selection (`host` default, sandbox scaffold disabled). +- `ToolDispatcher`: single dispatch seam for all tool execution (implemented through `ExecutorManager` in current runtime). - `ProactiveTriggerScheduler` (`src/runtime/proactive-trigger-scheduler.ts`): runs cron/heartbeat triggers, replays missed proactive runs from persisted history, enforces quiet-hours suppression, and exposes manual wake control for heartbeat demo triggers. +## Removed Legacy Seams (CP5.1 Step 6) +- `src/runtime/agent-config.ts` +- `src/runtime/ingress-binding-resolver.ts` +- `src/runtime/proactive-trigger-resolver.ts` +- `src/runtime/slash-command-router.ts` +- `src/runtime/trigger-normalizer.ts` + ## Non-Goals for Current Bootstrap - No full multi-surface adapter set beyond Slack yet. - No full multi-surface ingress matrix beyond Slack entrypoints yet. @@ -34,30 +46,41 @@ Keep moving parts explicit and replaceable. - No sandbox enforcement yet. ## Ownership and Rollback Notes +- Legacy seam rollback: removed CP5.1 seams are restored only via revision revert. - `DbClient` owner: platform runtime layer. - `DbClient` rollback path: swap `src/runtime/db.ts` back to direct `pg` access while preserving SQL contracts and migration files. - `SlackTransport` owner: platform runtime layer. - `SlackTransport` rollback path: disable live Slack connection in `src/index.ts` and fall back to no-op inbound logging while preserving normalized inbound event contracts. -- `TriggerNormalizer` owner: platform runtime layer. -- `TriggerNormalizer` rollback path: temporarily route Slack ingress directly to runtime handlers while preserving run lifecycle and stable IDs. +- `defineConfig` / `defineAgent` contracts owner: platform runtime layer. +- `defineConfig` / `defineAgent` rollback path: revert `agents/contracts.ts` to previous declaration shape while preserving required IDs (`agentId`, `sessionKey`, `runId`) in downstream runtime contracts. +- `AgentRegistry` owner: platform runtime layer. +- `AgentRegistry` rollback path: pin `agents/index.ts` to previous known-good declarations and keep DB projection unchanged. - `EventIdempotencyGuard` owner: platform runtime layer. - `EventIdempotencyGuard` rollback path: disable runtime pre-run duplicate checks and rely on `gravity.runs.source_event_id` uniqueness only. -- `AgentConfig` owner: platform runtime layer. -- `AgentConfig` rollback path: parse minimally typed config directly in repository/runtime call sites while preserving `gravity.agents.config` JSON shape. +- `SessionKeyBuilder` owner: platform runtime layer. +- `SessionKeyBuilder` rollback path: revert session-key builders to previous deterministic patterns while preserving DB session metadata and stable IDs. - `AgentSpecRepository` owner: platform runtime layer. - `AgentSpecRepository` rollback path: read minimal agent fields directly from `gravity.agents` and ignore advanced config blocks. - `SessionResolver` owner: platform runtime layer. - `SessionResolver` rollback path: revert to deterministic `sessionKey = {agentId}:{sourceEventId}` behavior. - `SessionCatalog` owner: platform runtime layer. - `SessionCatalog` rollback path: resolve sessions from `workspace/` path conventions only while preserving `gravity.sessions` schema for forward compatibility. -- `ConnectorRegistry` owner: platform runtime layer. -- `ConnectorRegistry` rollback path: hardcode a single connector path per agent in runtime code while preserving agent config columns. +- `ResourceRegistry` owner: platform runtime layer. +- `ResourceRegistry` rollback path: hardcode a single resource path per agent in runtime code while preserving agent config columns. +- `CapabilityCompiler` owner: platform runtime layer. +- `CapabilityCompiler` rollback path: inline capability expansion in runner/context code while preserving capability declarations in `agents/contracts.ts`. +- `SkillResolver` owner: platform runtime layer. +- `SkillResolver` rollback path: revert context assembly to direct shared skill loading while preserving capability-derived skill IDs. +- `AgentLocalSkillOverlay` owner: platform runtime layer. +- `AgentLocalSkillOverlay` rollback path: disable overlay loading and rely on shared skills only. - `ContextAssembler` owner: platform runtime layer. - `ContextAssembler` rollback path: inline context assembly in runner code while preserving per-turn reload semantics. - `RunLifecycleLogger` owner: platform runtime layer. - `RunLifecycleLogger` rollback path: revert runtime entrypoints to direct `console.log` messages while preserving stable ID fields in log lines. - `RunLogStore` owner: platform runtime layer. - `RunLogStore` rollback path: keep lifecycle log lines but disable `gravity.runs` writes from `src/index.ts` while retaining the DB schema contract. +- `ExecutorManager` owner: platform runtime layer. +- `ExecutorManager` rollback path: revert `pi-agent-runner` tool wiring to direct host tool construction while keeping runtime policy fields backward-compatible. - `ProactiveTriggerScheduler` owner: platform runtime layer. - `ProactiveTriggerScheduler` rollback path: disable scheduler startup and replay/wake control surfaces while preserving `proactiveTriggers` config contracts. - `PiAgentRunner` owner: platform runtime layer. diff --git a/docs/architecture/rearchitecture-decision.md b/docs/architecture/rearchitecture-decision.md new file mode 100644 index 0000000..d2908b3 --- /dev/null +++ b/docs/architecture/rearchitecture-decision.md @@ -0,0 +1,304 @@ +# Gravity Canonical Architecture Decision + +Status: accepted +Date: 2026-02-18 +Owners: kevin + codex +Scope: current migration baseline through CP11 demo readiness + +## Purpose +This document is the source of truth for Gravity's architecture direction during the current rearchitecture. + +It defines: +1. what we are building now, +2. canonical contracts and ownership boundaries, +3. explicit legacy removals, +4. pre-mortem risks and controls for downstream engineers. + +## Architecture Direction (Now) +Gravity is a **code-defined, control-plane-first multi-agent runtime**. + +Core commitments: +1. Agent definitions are TypeScript code, not Postgres JSONB. +2. Gravity owns routing, sessioning, scheduling, context assembly, and durable logging. +3. `pi-coding-agent` is used as a runtime library for turn execution/session primitives. +4. Tool execution goes through a single executor seam selected per-agent runtime policy. +5. The pre-MVP bias is simplicity: remove legacy paths after parity, do not keep parallel architectures. + +## Why This Direction +1. It removes accidental complexity from schema/normalization/resolver layers. +2. It keeps agent configuration legible to humans and coding agents. +3. It matches proven headless Slack patterns from `pi-mom` while preserving Gravity control-plane ownership. +4. It keeps security evolution seams explicit without building production-grade isolation too early. + +## Canonical Runtime Contracts + +### 1) Authoring Model + Source of Truth +- Agent definitions: `agents//agent.ts` +- Agent registry: `agents/index.ts` +- Shared resources: `store/shared/` +- Workspace runtime state: `workspace/` + +One-writer contract: +- Canonical behavior source: code-defined agent declarations. +- Queryable projection: Postgres runtime metadata for audit/search/reporting. +- Runtime execution must not depend on `gravity.agents.config` JSONB after migration completion. + +Compatibility window for skills/memory paths: +- Existing `store/` paths remain valid during this migration. +- Skill composition contract (2026-02-18 decision): canonical skill definitions live in `store/shared/skills`, and agents apply them via capability definitions referenced from `defineAgent(...).useCapabilities`. +- `store/agents/{agentId}/skills` is a legacy bridge and is scheduled for removal in CP6 (no long-lived compatibility acceptance). +- Memory remains agent-scoped at `store/agents/{agentId}/memory/MEMORY.md`. +- Final path consolidation for memory/resources (`store/` only vs full co-location) remains deferred. + +### 2) `defineConfig(...)` Minimum Contract +`defineConfig` must support: +1. Infra: DB, Slack, model provider credentials/config. +2. Defaults: model, runtime, session defaults, quiet-hours default. +3. Paths: shared resource root and workspace root. + +Resolution order for overridable fields: +1. Agent-level explicit value +2. Framework default +3. Hardcoded fallback + +Security guardrail: +- Credentials are loaded from environment/runtime secret providers; never hardcoded in agent declaration code. + +### 3) `defineAgent(...)` Minimum Contract +Required fields: +- `id` +- `name` +- `listen` +- `useCapabilities` + +Expected optional fields: +- `description` +- `model` +- `proactive` +- `resources` +- `runtime` +- `quietHours` +- `session` + +Resource config contract: +- Resource-specific settings must be nested inside the resource declaration (`resources` array). +- Example: DuckDB path is declared as `resources: [{ id: "warehouse", kind: "duckdb", path: "" }]`. +- Capabilities are declared through `useCapabilities: [{ capability: "", bindResources?: { "": "" } }]`. +- Capability definitions (catalog) own the skill set, required resource slots, and tool grants. +- No top-level resource-specific fields are allowed in `defineAgent(...)`. + +Policy boundary: +- Self-authoring may modify skills and memory files. +- Agent definition code changes are human-initiated in this phase. + +### 4) Runtime Ownership +Gravity runtime owns: +- Slack ingress/egress +- Listener routing and agent resolution +- Session key resolution and lifecycle +- Capability compilation + context assembly (capabilities, skills, memory, history, resource guidance) +- Proactive scheduling (`cron`, `heartbeat`, manual wake) +- Durable run and skill logging + +`pi-coding-agent` usage: +- `AgentSession` for turn execution loop +- `SessionManager` for session persistence/compaction primitives + +### 5) Tool Execution Boundary +- All tool calls dispatch through a single `Executor` seam. +- Runtime selection is per-agent via runtime config. +- Tool implementations stay executor-agnostic. +- Tool binding to executors happens in framework wiring. + +### 6) Durable State Contract +Canonical queryable state: +- `gravity.agents` (registry projection/metadata) +- `gravity.sessions` (session metadata and ownership) +- `gravity.runs` +- `gravity.skill_versions` + +Canonical file state: +- skills/memory/shared resources on disk + +Canonical ephemeral state: +- session and scratch state in `workspace/` + +### 7) Session Contract +Session boundaries are mode-dependent (`thread`, `main`, `isolated`). + +Canonical session key patterns: +- Main mode: `{agentId}:main` +- Thread mode (Slack thread): `{agentId}:{threadTs}` +- Thread mode DM fallback (no thread id): `{agentId}:{channelId}` +- Isolated mode (per inbound event): `{agentId}:{sourceEventId}` +- Proactive thread mode: `{agentId}:proactive:{triggerId}:thread` +- Proactive isolated mode: `{agentId}:proactive:{triggerId}:{sourceEventId}` + +Dual history is canonical: +- permanent transcript (`log.jsonl`) +- compactable model context (`context.jsonl`) + +### 8) Stable IDs +Mandatory IDs across runtime flows and docs: +- `agentId` +- `sessionKey` +- `runId` + +## Scope Boundaries + +### In Scope Now +- Code-defined agents as first-class runtime contracts +- Control-plane-owned runtime flow +- Executor seam as sandbox-ready boundary +- Capability-first composition with shared-skill catalog + resource bindings +- Removal of JSONB-driven legacy routing/resolver modules after parity + +### Explicitly Deferred +- Full extensibility framework and plugin ecosystem +- Phoenix-style TurnContext pipeline/plugs as a formal public API +- Multi-surface abstraction beyond Slack for this phase +- Runtime hot-reload of full agent definitions +- Marketplace/distribution concerns + +## Migration Sequence (Execution Order) +1. Add `defineConfig` and `defineAgent` contracts plus `agents/index.ts` registry. +2. Add a compile step that produces typed runtime declarations for ingress, proactive triggers, sessions, and trigger dimensions. +3. Rewire Slack slash/message/proactive paths to consume compiled declarations. +4. Rewire context assembly to code-defined agent sources (capabilities/skills/memory/resources). +5. Prove parity via verification + smoke matrix. +6. Delete legacy JSONB-driven modules (no long-lived dual path). + +## Legacy Module Removal Decisions +The following modules are explicitly targeted for removal once compiled code-defined declarations are in use. + +| Module | Decision | Replacement seam | +| --- | --- | --- | +| `src/runtime/agent-config.ts` | Remove | compiled `defineAgent` + `defineConfig` declarations | +| `src/runtime/ingress-binding-resolver.ts` | Remove | compiled listener routing map | +| `src/runtime/proactive-trigger-resolver.ts` | Remove | compiled proactive schedule declarations | +| `src/runtime/slash-command-router.ts` | Remove | compiled slash command listener declarations | +| `src/runtime/trigger-normalizer.ts` | Remove | typed trigger dimensions emitted directly at ingress/proactive boundaries | + +Removal gate: +- No runtime imports of these modules remain. +- `npm run check`, `npm run verify:cp5`, and `npm run verify:cp10` all pass. +- Live smoke matrix passes. + +## Done Criteria for CP5.1 Rearchitecture Parity +Required before declaring CP5.1 complete: +1. `npm run check` passes. +2. `npm run verify:cp5` passes. +3. `npm run verify:cp10` passes. +4. Legacy module removal gate is satisfied (all listed modules removed, no imports remain). +5. Live smoke matrix passes: + - slash command + - app mention + - thread reply + - DM path + - DuckDB answer quality + - run log persistence + - proactive wake path +6. Stable IDs (`agentId`, `sessionKey`, `runId`) are preserved end-to-end. + +CP6 note: +- Session/memory scaffolding stays paused until CP5.1 parity exits green. + +## Resolved Questions (Answered Now) + +1. **Should agent behavior configuration stay in Postgres JSONB?** +Answer: No. Agent behavior is code-defined. Postgres stores queryable runtime/audit state. + +2. **Should pi extensions be the primary authoring model?** +Answer: No. Use `pi-coding-agent` as a library; keep Gravity as the control plane. + +3. **Should we build the full Phoenix-style pipeline/plugs system now?** +Answer: No. Defer until a concrete cross-cutting threshold is met. + +4. **Should self-authoring modify agent definition code in this phase?** +Answer: No. Self-authoring is limited to skills and memory. + +5. **What is the DM fallback contract when thread mode has no thread id?** +Answer: Use `{agentId}:{channelId}`. + +6. **How does rollback work after legacy-module deletion?** +Answer: Rollback is deployment-level: revert to the pre-removal tagged revision while keeping durable schema/contracts unchanged (`gravity.runs`, `gravity.sessions`, `gravity.skill_versions`). + +7. **Should skills be split between shared files and agent-local folders as a long-term model?** +Answer: No. Long-term model is shared-skill catalog + capability catalog + explicit agent capability bindings, and CP6 removes agent-local skill loading as an immediate migration gate. + +## Deferred Questions for Downstream Implementers +These are intentionally deferred. Each item includes trigger conditions and the default until decided. + +1. **Memory/resource path end-state: keep `store/` as canonical vs full co-location under `agents/`?** +Context: skills are decided shared-first; memory/resources path end-state is still open. +Trigger: decide before deleting compatibility loaders. +Default until decided: support current `store/` + code-defined agents without forcing a path migration. + +2. **When to introduce formal TurnContext pipeline + plugs?** +Context: Phoenix-inspired design is compelling but adds framework surface area. +Trigger: at least three cross-cutting concerns need lifecycle interception (for example tool policy + cost tracking + audit transforms). +Default until decided: keep explicit stage functions and direct composition. + +3. **Pipeline insertion syntax (named insertion points vs category buckets)?** +Context: named points are precise; buckets are simpler. +Trigger: first implementation of pluggable turn middleware. +Default until decided: do not expose user plug insertion API. + +4. **TurnContext mutability model (mutable object vs immutable returns)?** +Context: immutable is safer; mutable is simpler in TypeScript runtime code. +Trigger: first formal plug API design. +Default until decided: keep internal mutable context with strict stage ownership conventions. + +5. **Framework-level vs agent-level plug ordering model?** +Context: ordering affects audit and policy correctness. +Trigger: first introduction of global + per-agent middleware at same insertion point. +Default until decided: framework-before-agent ordering. + +6. **Extension escape hatch for advanced use cases?** +Context: may be useful for custom providers or advanced interception later. +Trigger: concrete requirement that cannot be met by current `defineAgent` + executor + control-plane seams. +Default until decided: no extension escape hatch in current runtime. + +## Pre-Mortem (What Could Go Wrong) +1. **Split-brain between code declarations and DB registry projection** +Failure mode: routing or scheduling reads stale DB data and diverges from code. +Control: code declarations are the only runtime behavior source; DB is projection only; fail startup on projection mismatch. + +2. **Session key drift across entrypoints** +Failure mode: duplicate or fragmented sessions; compaction and replay become inconsistent. +Control: central session key builder contract + matrix tests across slash/app mention/thread/DM/proactive. + +3. **Replay storm or duplicate proactive deliveries after restart** +Failure mode: noisy channels, duplicate runs, loss of operator trust. +Control: bounded replay caps, idempotency checks, quiet-hours suppression, manual wake audit trail. + +4. **Dual-history corruption (`log.jsonl` vs `context.jsonl`) during compaction** +Failure mode: lost chronology or invalid model context. +Control: strict ownership rules (`log.jsonl` append-only, `context.jsonl` compactable) + recovery retry path. + +5. **Legacy removal regresses ingress coverage** +Failure mode: one or more paths (slash/thread/DM/proactive) silently stop routing. +Control: required smoke matrix + CP5/CP10 verification before declaring migration done. + +6. **Credential leakage into code-defined configs** +Failure mode: secrets committed to repo and leaked in logs. +Control: secrets only from environment/runtime providers; no raw credential literals in agent declaration files. + +7. **New engineers cannot reconstruct intent and rollback steps quickly** +Failure mode: slow incident response, ad hoc architectural forks. +Control: this doc stays authoritative, `docs/README.md` links to it, and rollback is defined as a deployment-level revision revert. + +## Engineer Takeover Checklist +1. Read this document first. +2. Read `docs/architecture/system-map.md` and `docs/architecture/interfaces.md` for current runtime topology. +3. Read `docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md` for active execution details. +4. Run `npm run check` and both verification harnesses (`npm run verify:cp5`, `npm run verify:cp10`). +5. Confirm removed legacy modules do not exist/import anywhere in runtime paths. + +## Operational Rule +When older docs conflict with this decision, this file is authoritative for current architecture direction until alignment updates land. + +## Follow-Up (Separate Changes) +1. Align `mvp_requirements.md` with this architecture decision. +2. Align checkpoint/docs contracts to this source of truth. +3. Keep verification gates stable (`npm run check`) throughout migration. diff --git a/docs/architecture/system-map.md b/docs/architecture/system-map.md index a166992..b7e030a 100644 --- a/docs/architecture/system-map.md +++ b/docs/architecture/system-map.md @@ -4,17 +4,22 @@ - Runtime process: `src/` (single process scaffold, later to host Slack loop + scheduling + tool dispatch). - Queryable durable state: Postgres schema `gravity` (versioned in `db/migrations/`, bootstrap snapshot in `schema.sql`). - Query layer: Kysely + pg dialect (`src/runtime/db.ts`). -- Durable file state: `store/` (agent skills, memory, shared connectors, shared knowledge). +- Durable file state: `store/` (shared skills/resources/knowledge, agent memory; agent-local skill overlays are pending removal in CP6). - Ephemeral runtime state: `workspace/` (session logs, compactable context, scratch). +CP5.1 migration note: +- Runtime behavior source of truth is moving to code-defined agent declarations. +- `gravity.agents` remains a queryable projection/registry surface, not canonical behavior config. +- Capability composition source of truth is `defineAgent(...).useCapabilities`; capabilities resolve shared skills/resources/tool grants, and CP6 removes agent-local skill directory loading before broader session scaffolding work. + ## Durable State Contract -- `gravity.agents`: canonical agent registry. +- `gravity.agents`: queryable agent registry projection and metadata surface. - `gravity.sessions`: canonical session metadata registry (mode/ownership/status). - `gravity.runs`: canonical run log and audit surface. - `gravity.skill_versions`: canonical skill evolution log. -- `store/agents/{agentId}/skills`: agent-specific behavioral instructions. +- `store/shared/skills`: canonical skill catalog inherited/composed by all agents (platform primitives + namespaced agent-specific modules). +- `store/agents/{agentId}/skills`: legacy path scheduled for removal in CP6; runtime target state does not load this path. - `store/agents/{agentId}/memory/MEMORY.md`: persistent memory loaded each turn. -- `store/shared/skills`: platform primitives inherited by all agents. ## Session Contract (Target) - Session key format: mode-dependent (`{agent-id}:main`, `{agent-id}:{thread_ts}`, `{agent-id}:{source_event_id}`, and proactive keys under `{agent-id}:proactive:*`). @@ -24,12 +29,12 @@ - Cross-session search log: `workspace/{agent-id}/agent-log.jsonl`. ## Integration Targets -- Slack Socket Mode routing from slash commands, app mentions, thread replies, and direct messages via `ingressBindings`. +- Slack Socket Mode routing from slash commands, app mentions, thread replies, and direct messages via compiled code-defined listener declarations. - Routed slash command acknowledgements should return `response_type: ephemeral`; runtime then posts a visible root thread message before replying in thread. Unmapped slash commands should acknowledge with `response_type: ephemeral`. -- Non-slash message triggers are enabled through explicit per-agent ingress bindings. -- Proactive triggers (`cron`, `heartbeat`) run from `gravity.agents.config.proactiveTriggers` with delivery routing to Slack channel thread or Slack DM user. +- Non-slash message triggers are enabled through explicit per-agent listener declarations. +- Proactive triggers (`cron`, `heartbeat`) run from compiled code-defined proactive declarations with delivery routing to Slack channel thread or Slack DM user. - Proactive scheduler reconciles missed runs from persisted run history on startup/reload windows and supports manual wake controls for heartbeat triggers. - Quiet-hours policy can suppress proactive replay/scheduled runs while allowing explicit manual bypass. - Source-event idempotency is enforced before run execution (in-flight guard + `gravity.runs.source_event_id` check). - Claude API loop with compaction and tool-result truncation from `mvp_requirements.md`. -- DuckDB connector at `/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb` for Wiggs. +- DuckDB resource at `/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb` for Wiggs. diff --git a/docs/checkpoints/mvp-status.md b/docs/checkpoints/mvp-status.md index 1dd128c..4c2dc9c 100644 --- a/docs/checkpoints/mvp-status.md +++ b/docs/checkpoints/mvp-status.md @@ -9,16 +9,15 @@ Last Updated: 2026-02-18 | CP3 | complete | Slack Socket Mode transport is live with slash-command ingestion, static slash routing (`/wiggs` -> `data-analyst`, `/compliance` -> `compliance-helper`), deterministic slash acknowledgements (`ephemeral` for routed and unmapped commands), and `gravity.runs` lifecycle persistence on routed commands. Routed slash commands are then surfaced by posting a root thread message before threaded replies. CP3 established the slash baseline; non-slash ingress expansion is now tracked in CP4. | `npm run check`, `/wiggs ` in Slack, `docker compose exec -T postgres psql -U gravity -d gravity -c "SELECT id, agent_id, session_key, source_event_id, status FROM gravity.runs ORDER BY started_at DESC LIMIT 5;"` | | CP4 | complete | End-to-end Wiggs runtime is live with Claude loop integration, per-turn skill + dbt context loading, output truncation protections, and ingress-binding coverage for slash + `app_mention` + thread/DM message paths. Stable run/session dimensions and source-event idempotency checks are in place. CP10 scheduler foundations delivered during CP4 remain tracked as a separate completion pass. | `docs/plans/completed/2026-02-18-cp4-wiggs-e2e.md`, `/wiggs ` in Slack, `npm run check` | | CP5 | complete | Run logging and `store/` conventions are verified with a DB-backed CP5 harness (`npm run verify:cp5`), store-convention invariants, explicit shared skill contracts (`query-gravity`, `rollback`), and evidence queries across slash/non-slash/proactive/failure run dimensions. | `docs/plans/completed/2026-02-18-cp5-run-logging-store-conventions.md`, `docs/checkpoints/cp5-verification.md`, `npm run verify:cp5`, `docker compose exec -T postgres psql -U gravity -d gravity -c "SELECT trigger_kind, entrypoint, status, count(*) AS run_count FROM gravity.runs GROUP BY trigger_kind, entrypoint, status ORDER BY trigger_kind, entrypoint, status;"` | -| CP6 | in_progress | CP6 execution is open to implement session + memory scaffolding (dual-history files, compaction seams, memory reload behavior, and startup backfill/session hook scaffolds). | `docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md` | -| CP7 | not_started | Session/memory tests not implemented yet. | N/A | +| CP5.1 | complete | Rearchitecture parity checkpoint is complete: contracts/registry/declaration compilation, slash/message/proactive cutovers, `ExecutorManager` seam consolidation, legacy JSONB seam removal, first-class `useCapabilities[]` + `bindResources` contracts, and parity gates all passed (`verify:cp5`, `verify:cp10`, `check`, `lint:repo`). | `docs/plans/completed/2026-02-18-cp5-1-rearchitecture-parity.md`, `docs/architecture/rearchitecture-decision.md`, `agents/index.ts`, `agents/contracts.ts`, `src/index.ts`, `src/runtime/session-key.ts`, `src/runtime/executor-manager.ts`, `src/runtime/proactive-trigger-scheduler.ts` | +| CP6 | in_progress | CP6 starts with an immediate gate: migrate and remove agent-local skills (`store/agents/*/skills`) so runtime uses shared skill IDs only, then continue session/compaction scaffolding. | `docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md` | +| CP7 | not_started | Session/memory tests are blocked until CP5.1 parity completes and CP6 resumes. | N/A | | CP8 | not_started | Self-authoring runtime loop not implemented yet. | N/A | | CP9 | not_started | Second agent runtime behavior not implemented yet. | N/A | | CP10 | complete | Proactive runtime now includes replay/backfill reconciliation from durable run history, manual wake controls (`!wake` command text on mapped slash commands), and quiet-hours suppression with optional manual bypass. CP10 verification harness validates replay/manual/quiet-hours behavior and proactive run-log persistence contracts. | `docs/plans/completed/2026-02-18-cp10-proactive-validation-hardening.md`, `docs/checkpoints/cp10-verification.md`, `npm run verify:cp10`, `src/runtime/proactive-trigger-scheduler.ts` | | CP11 | not_started | Demo polish and rehearsal not started. | N/A | ## Next Milestones -- Implement CP6 dual-history session files (`log.jsonl` + `context.jsonl`) with deterministic ownership and update flows. -- Add and validate compaction + retry behavior under context overflow scenarios. -- Add startup backfill and session-end memory hook scaffolding. -- Enforce pre-MVP fail-closed config behavior: invalid values must emit runtime warnings and disable affected paths. -- Execute CP7 test matrix once CP6 scaffolding is in place. +- Execute CP6 immediate skill-boundary gate (migrate local skills and remove agent-local skill loading). +- Execute CP6 session + memory scaffolding work items and validation matrix. +- Execute CP7 test matrix after CP6 scaffolding lands. diff --git a/docs/operations/runbook.md b/docs/operations/runbook.md index 75931d9..994c45c 100644 --- a/docs/operations/runbook.md +++ b/docs/operations/runbook.md @@ -28,6 +28,13 @@ SELECT id, name, status, channel_id FROM gravity.agents; 3. Run full repository gates: `npm run check` 4. Optional: inspect matrix and live SQL samples in `docs/checkpoints/cp5-verification.md` +## CP5.1 Rearchitecture Parity Commands +1. Ensure Postgres is running and schema is current: `npm run db:up && npm run db:apply` +2. Validate CP5 parity surface: `npm run verify:cp5` +3. Validate proactive parity surface: `npm run verify:cp10` +4. Run full repository gates: `npm run check` +5. Confirm active plan and decision contract: `docs/plans/active/2026-02-18-cp5-1-rearchitecture-parity.md`, `docs/architecture/rearchitecture-decision.md` + ## CP10 Verification Commands 1. Ensure Postgres is running and schema is current: `npm run db:up && npm run db:apply` 2. Run CP10 verification harness: `npm run verify:cp10` @@ -38,5 +45,5 @@ SELECT id, name, status, channel_id FROM gravity.agents; ## Notes - `db/migrations/` is the source of truth for schema changes; `schema.sql` is a bootstrap snapshot. - `seed.sql` contains workspace-specific Slack `channel_id` values. Update them before applying in a different workspace. -- Wiggs DuckDB connector path assumes local clone at `/Users/kevingalang/code/jaffle_shop_duckdb`. +- Wiggs DuckDB resource path assumes local clone at `/Users/kevingalang/code/jaffle_shop_duckdb`. - Slack setup requirements (Socket Mode, scopes, events, App Home DM input, slash commands) are documented in `docs/operations/slack-app-setup.md`. diff --git a/docs/plans/README.md b/docs/plans/README.md index 973ff38..7931d1b 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -1,6 +1,7 @@ # Execution Plans - Active plans live in `docs/plans/active/`. +- On-hold plans live in `docs/plans/on-hold/`. - Completed plans move to `docs/plans/completed/`. - Keep exactly one active plan per execution thread. - Every active plan includes `Status: active` and `Last Updated: YYYY-MM-DD`. diff --git a/docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md b/docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md index b649d45..3dd6e54 100644 --- a/docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md +++ b/docs/plans/active/2026-02-18-cp6-sessions-memory-scaffolding.md @@ -3,6 +3,15 @@ Status: active Owner: kevin + codex Last Updated: 2026-02-18 +Thread: cp6-sessions-memory-scaffolding + +## Resume Context +CP5.1 rearchitecture parity is complete and CP6 is unblocked. + +Resume evidence: +- `docs/plans/completed/2026-02-18-cp5-1-rearchitecture-parity.md` exit criteria are met. +- Legacy JSONB-driven routing/resolver modules are removed. +- `npm run verify:cp5` and `npm run verify:cp10` are green after cutover. ## Goal Deliver CP6 session and memory scaffolding so thread-level context is durable, compactable, and recoverable across runtime restarts. @@ -11,11 +20,21 @@ Deliver CP6 session and memory scaffolding so thread-level context is durable, c CP6 focuses on runtime session state shape and compaction mechanics. Keep CP10 proactive behavior intact and avoid self-authoring automation work reserved for CP8. Pre-MVP configuration policy is fail-closed: invalid config must stop feature activation and emit a runtime warning for immediate visibility. Post-demo, this policy can be revisited. +## Priority Update (2026-02-18) +Before deeper session/compaction work, simplify the skill abstraction boundary: +- Canonicalize skill definitions under `store/shared/skills`. +- Keep `defineAgent(...).skills` bindings as the explicit skill application surface. +- Migrate and remove `store/agents/{agentId}/skills` immediately in this checkpoint (no long-lived compatibility period). + ## CP6 In/Out -- In scope: dual-history files (`log.jsonl`, `context.jsonl`), session isolation by thread/session mode, per-turn `MEMORY.md` loading contract, compaction trigger + retry behavior, startup backfill/pre-run sync seams, and session-end memory hook scaffolding. +- In scope: skill-boundary simplification (shared catalog + explicit agent bindings), dual-history files (`log.jsonl`, `context.jsonl`), session isolation by thread/session mode, per-turn `MEMORY.md` loading contract, compaction trigger + retry behavior, startup backfill/pre-run sync seams, and session-end memory hook scaffolding. - Out of scope: full CP7 reliability matrix, CP8 auto-commit/self-author loop, and CP11 demo polish. ## Work Items +- [ ] Lock docs contract: shared-skill catalog is canonical and agent-local skills folders are removal targets in this checkpoint. +- [ ] Migrate existing agent-local skill docs to namespaced entries in `store/shared/skills`. +- [ ] Remove runtime loading of `store/agents/{agentId}/skills` from context assembly and keep only declared shared skill loading. +- [ ] Delete agent-local skills directories and update invariants/tests/verification scripts to enforce shared-skill-only topology. - [ ] Define CP6 validation matrix for session isolation, compaction, and memory reload behavior. - [ ] Implement dual-history session file contract under `workspace/{agentId}/sessions/{sessionKey}/`. - [ ] Ensure per-turn `MEMORY.md` is loaded and reflected on immediate next turn. @@ -26,11 +45,13 @@ Pre-MVP configuration policy is fail-closed: invalid config must stop feature ac - [ ] Update architecture/checkpoint/reliability docs with session boundary and rollback details. ## Risks +- Skill migration can silently drop behavioral instructions if shared skill IDs and bindings are not aligned before local folder removal. - Compaction can corrupt context ordering if log/context ownership is not explicit. - Backfill logic can produce duplicate transcript entries without strict event identity handling. - Session-end memory writes can race with live inbound events. ## Exit Criteria +- Skill definitions are composed through declared shared skill IDs, and runtime no longer reads `store/agents/{agentId}/skills`. - CP6 session scaffolding is implemented with deterministic file contracts and thread isolation. - Compaction and memory reload behavior are validated in tests. - Startup backfill seam and memory hook scaffolds are in place. diff --git a/docs/plans/completed/2026-02-18-cp4-wiggs-e2e.md b/docs/plans/completed/2026-02-18-cp4-wiggs-e2e.md index c325984..4524526 100644 --- a/docs/plans/completed/2026-02-18-cp4-wiggs-e2e.md +++ b/docs/plans/completed/2026-02-18-cp4-wiggs-e2e.md @@ -48,7 +48,7 @@ Notes: - [x] Add source-event idempotency guard checks for slash + non-slash ingress before run execution. - [x] Implement proactive trigger runtime (`proactiveTriggers`: `cron` + `heartbeat`) with delivery routing (`channel_thread`, `dm`) as a pulled-forward CP10 foundation slice. - [x] Load shared and agent-specific skills from `store/` every turn (no runtime cache). -- [x] Add/verify DuckDB connector skill path wiring and query usage contract for Wiggs. +- [x] Add/verify DuckDB resource skill path wiring and query usage contract for Wiggs. - [x] Add/verify query-pattern and response-formatting skills for Wiggs. - [x] Add schema/doc context loading so Wiggs can reason with dbt metadata. - [x] Implement tool result truncation behavior for large command/read outputs. diff --git a/docs/plans/completed/2026-02-18-cp5-1-rearchitecture-parity.md b/docs/plans/completed/2026-02-18-cp5-1-rearchitecture-parity.md new file mode 100644 index 0000000..6826339 --- /dev/null +++ b/docs/plans/completed/2026-02-18-cp5-1-rearchitecture-parity.md @@ -0,0 +1,167 @@ +# CP5.1 Plan (Rearchitecture + Parity) + +Status: complete +Owner: kevin + codex +Last Updated: 2026-02-18 + +## Goal +Complete the CP5.1 rearchitecture so runtime behavior is driven by code-defined agent declarations and reaches parity with the current Slack/message/proactive behavior before resuming downstream checkpoints. + +## Scope Decision +CP5.1 is a control-plane migration checkpoint between CP5 and CP6. + +- In scope: code-defined `defineConfig`/`defineAgent` contracts, compiled runtime declarations, removal of JSONB-driven resolver/router modules, parity verification, and docs alignment. +- Out of scope: CP6 session/memory scaffolding implementation details, CP7 reliability matrix, CP8 self-authoring automation, and CP11 demo polish. + +## CP5.1 In/Out +- In scope: contract lock + registry assembly, declaration compilation, ingress/proactive/session cutovers, executor seam consolidation, legacy module removal, parity verification, and checkpoint/docs updates. +- Out of scope: CP6 dual-history/compaction implementation, CP7 reliability matrix expansion, CP8 self-authoring runtime loop, and CP11 demo polish. + +## Step-by-Step Execution Plan (Ordered) + +### Step 1 - Lock contracts + typed registry +Status: complete + +Deliverables: +- Define and lock `defineConfig(...)` and `defineAgent(...)` minimum contracts per `docs/architecture/rearchitecture-decision.md`. +- Assemble typed registry in `agents/index.ts` with explicit `agentId` ownership and duplicate/collision guards. +- Establish code declarations as the canonical authoring source and prepare runtime cutover (runtime behavior source-of-truth cutover lands in Steps 2-4). + +Gate: +- `npm run check` + +### Step 2 - Compile runtime declarations from code +Status: complete + +Deliverables: +- Emit typed declarations for ingress listeners, proactive triggers, and session dimensions from code-defined agents. +- Compile trigger dimensions directly at ingress/proactive boundaries (no trigger normalizer dependency). +- Keep stable IDs explicit through compiled shapes (`agentId`, `sessionKey`, `runId`). + +Gate: +- `npm run check` + +### Step 3 - Cut over slash + message ingress routing +Status: complete + +Deliverables: +- Rewire slash routing to compiled listener declarations. +- Rewire app mention, thread reply, and DM ingress resolution to compiled declarations. +- Preserve existing ack behavior and run lifecycle writes in `gravity.runs`. + +Gate: +- `npm run check` + +### Step 4 - Cut over proactive routing + session keys +Status: complete + +Deliverables: +- Rewire proactive trigger resolution/scheduling to compiled proactive declarations. +- Validate canonical session key patterns across slash/app mention/thread/DM/proactive entrypoints. +- Preserve durable state contract (`gravity.runs`, `gravity.sessions`, `gravity.skill_versions`). + +Gate: +- `npm run check` + +### Step 5 - Consolidate executor seam +Status: complete + +Deliverables: +- Introduce executor-manager wiring so all tool calls route through one `Executor` seam. +- Select runtime per-agent policy (host default; sandbox scaffold disabled for this checkpoint). +- Keep tool implementations executor-agnostic. + +Gate: +- `npm run check` + +### Step 6 - Remove JSONB-driven legacy modules +Status: complete + +Deliverables: +- Remove the following modules after cutovers are fully wired: + - `src/runtime/agent-config.ts` + - `src/runtime/ingress-binding-resolver.ts` + - `src/runtime/proactive-trigger-resolver.ts` + - `src/runtime/slash-command-router.ts` + - `src/runtime/trigger-normalizer.ts` +- Verify no runtime imports remain. + +Gate: +- `npm run check` + +### Step 7 - Prove parity + close docs/checkpoints +Status: complete + +Deliverables: +- Verify CP5/CP10 harness parity and live smoke matrix. +- Update docs/checkpoints/plan state in the same change set. +- Mark CP5.1 complete and unblock CP6 only after all gates pass. + +Gate: +- `npm run verify:cp5` +- `npm run verify:cp10` +- `npm run check` +- `npm run lint:repo` + +## Evidence Snapshot (Step 1, 2026-02-18) +- Added code-defined contracts in `agents/contracts.ts` (`defineConfig`, `defineAgent`, and resolution-order helpers). +- Added code-defined agent declarations in `agents/data-analyst/agent.ts` and `agents/compliance-helper/agent.ts`. +- Added typed registry assembly + collision guards in `agents/index.ts` (duplicate `agentId` and slash-command collision checks). +- Added unit coverage in `tests/agents/contracts.test.ts` and `tests/agents/index.test.ts`. +- Verification gates passed after Step 1 changes: `npm run check`, `npm run build`. +- Runtime routing/resolution remains on legacy DB JSONB seams until Step 2-6 cutovers are complete. +- Added compiled declaration outputs in `agents/index.ts`: + - ingress listeners (slash + message), + - proactive triggers (resolved delivery/session dimensions), + - session dimensions (`sessionKey` pattern contracts), + - trigger dimensions (`triggerKind`, `surface`, `entrypoint`, `runId` pattern). +- Added Step 2 unit coverage in `tests/agents/index.test.ts` for compiled declarations, proactive/session dimension compilation, and fail-closed proactive delivery checks. +- Cut over slash command routing in `src/index.ts` to compiled declarations (`compiledDeclarations.ingress.slashCommands`) and removed runtime dependency on legacy slash router map for slash decisions. +- Cut over app mention/thread reply/DM message ingress resolution in `src/index.ts` to compiled declaration listeners with active-agent DB filtering (`status = active`) and existing channel-affinity/thread-owner behavior preserved. +- Slash/message trigger dimensions now come directly from compiled declaration trigger contracts at ingress boundaries (no slash/message trigger normalization calls in `src/index.ts`). +- Cut over proactive scheduler trigger loading in `src/index.ts` + `src/runtime/proactive-trigger-scheduler.ts` to compiled declarations (`compiledDeclarations.proactive.triggers`) filtered by active DB agent status, removing runtime proactive routing dependency on JSONB resolver/config parsing. +- Added `src/runtime/session-key.ts` canonical builders and `tests/runtime/session-key.test.ts` coverage for canonical session key patterns across slash/message/proactive modes (main/thread/isolated + DM thread fallback). +- Proactive trigger dimensions at runtime ingress now come from scheduler-emitted typed trigger dimensions (`event.trigger`) rather than proactive trigger normalization helpers. +- Added `src/runtime/executor-manager.ts` as a single `Executor` seam for tool execution with per-agent runtime selection (`host` default, `sandbox` scaffold disabled). +- Wired `src/runtime/pi-agent-runner.ts` tool binding through `ExecutorManager.resolve(runtime)` instead of direct tool construction, and wired runtime policy selection in `src/index.ts` via `agentRegistry` declarations. +- Added `tests/runtime/executor-manager.test.ts` coverage for host runtime resolution and fail-closed sandbox behavior. +- Removed legacy modules from runtime: + - `src/runtime/agent-config.ts` + - `src/runtime/ingress-binding-resolver.ts` + - `src/runtime/proactive-trigger-resolver.ts` + - `src/runtime/slash-command-router.ts` + - `src/runtime/trigger-normalizer.ts` +- Removed legacy unit tests tied to deleted modules and updated runtime paths/scripts to compile and run without those seams. +- Step 7 parity gates passed after Step 6 cutover: + - `npm run verify:cp5` + - `npm run verify:cp10` + - `npm run check` + - `npm run lint:repo` +- Closed CP5.1 docs/checkpoint state: plan marked complete, checkpoint board updated, and CP6 unblocked for active execution. + +## Parity Matrix (Required) +- Slash command routing + ack behavior parity. +- App mention + thread reply + DM ingress parity. +- Proactive replay/manual wake/quiet-hours parity. +- Run lifecycle persistence parity (`runId`, `agentId`, `sessionKey`, `source_event_id`). + +## Legacy Removal Gate +- `src/runtime/agent-config.ts` removed. +- `src/runtime/ingress-binding-resolver.ts` removed. +- `src/runtime/proactive-trigger-resolver.ts` removed. +- `src/runtime/slash-command-router.ts` removed. +- `src/runtime/trigger-normalizer.ts` removed. +- No runtime imports remain for removed modules. + +## Risks +- Hidden behavior dependencies on `gravity.agents.config` can break routing after cutover. +- Session key drift can fragment history and invalidate CP6 assumptions. +- Legacy removal can regress edge cases if parity checks are too narrow. + +## Exit Criteria +- Runtime uses code-defined declarations as the behavior source of truth. +- Legacy modules listed in `docs/architecture/rearchitecture-decision.md` are removed. +- `npm run check` passes. +- `npm run verify:cp5` passes. +- `npm run verify:cp10` passes. +- Checkpoint/docs state reflects CP5.1 completion and CP6 resume readiness. diff --git a/docs/plans/completed/2026-02-18-cp5-run-logging-store-conventions.md b/docs/plans/completed/2026-02-18-cp5-run-logging-store-conventions.md index 6723314..6f51da4 100644 --- a/docs/plans/completed/2026-02-18-cp5-run-logging-store-conventions.md +++ b/docs/plans/completed/2026-02-18-cp5-run-logging-store-conventions.md @@ -18,7 +18,7 @@ CP5 focuses on verification and contract-hardening, not major runtime expansion. - [x] Define CP5 verification matrix for slash, non-slash, and proactive trigger runs. - [x] Validate `gravity.runs` lifecycle writes (start, success/failure, summaries, stable IDs) across trigger paths. - [x] Validate failure-path persistence in `gravity.runs` (error fields + completion timestamps). -- [x] Verify `store/` directory contracts remain stable (`shared/skills`, `shared/connectors`, `agents/{agentId}/{skills,memory}`). +- [x] Verify `store/` directory contracts remain stable (`shared/skills`, `shared/resources`, `agents/{agentId}/{skills,memory}`). - [x] Validate `store/shared/skills/query-gravity.md` behavior for agent/config/run introspection. - [x] Validate `store/shared/skills/rollback.md` behavior with a controlled skill edit + git rollback. - [x] Confirm `store/` versioning workflow uses the repo git history cleanly (no nested repo requirement). diff --git a/docs/tech-debt-tracker.md b/docs/tech-debt-tracker.md index c865193..01d00e1 100644 --- a/docs/tech-debt-tracker.md +++ b/docs/tech-debt-tracker.md @@ -9,4 +9,4 @@ Last Updated: 2026-02-18 | TD-003 | No CI migration smoke test yet | Migration regressions can slip until manual runtime checks | Add CI job for `npm run db:up && npm run db:migrate && npm run db:apply` | | TD-004 | `schema.sql` snapshot is maintained manually | Schema snapshot can drift from `db/migrations/` over time | Add a repo check that validates `schema.sql` matches migration output | | TD-005 | Shutdown path does not enforce best-effort process exit when Slack disconnect fails | Runtime can hang or exit non-deterministically during SIGINT/SIGTERM under socket/network failure | Wrap Slack transport stop in guarded shutdown logic (`try/finally`), log disconnect errors, and always complete process termination | -| TD-006 | No `npm run doctor` command for runtime/live-definition misconfiguration checks | Misconfigured agent specs, connector paths, ingress bindings, required CLIs, or runtime writability can fail late at runtime | Add `npm run doctor` with scoped runtime checks that explicitly avoid overlap with TypeScript/static guarantees | +| TD-006 | No `npm run doctor` command for runtime/live-definition misconfiguration checks | Misconfigured agent specs, resource paths, ingress bindings, required CLIs, or runtime writability can fail late at runtime | Add `npm run doctor` with scoped runtime checks that explicitly avoid overlap with TypeScript/static guarantees | diff --git a/mvp_requirements.md b/mvp_requirements.md index 11fff4c..0efbf06 100644 --- a/mvp_requirements.md +++ b/mvp_requirements.md @@ -1,6 +1,6 @@ # Gravity — MVP Requirements -_Last updated: 2026-02-17 (v2)_ +_Last updated: 2026-02-18 (v2)_ _Target: Working demo for Immad call, Thursday 2026-02-20_ _Build time budget: ~24 hours_ @@ -252,10 +252,10 @@ Every time an agent responds, it assembles context from agent-scoped paths: ``` [0] System Prompt ├── Agent identity and role (from gravity.agents registry) - ├── Shared skills (store/shared/skills/*.md) - ├── Agent-specific skills (store/agents/{agent-id}/skills/*.md) + ├── Capability-derived shared skills (store/shared/skills/*.md) + ├── Agent-specific skill overlays (store/agents/{agent-id}/skills/*.md; migration bridge) ├── Memory (store/agents/{agent-id}/memory/MEMORY.md) - └── Connector configs (store/shared/connectors/ + agent config) + └── Resource docs/config (store/shared/resources/ + capability bindResources) [1] Conversation History (from workspace/{agent-id}/sessions/{session-key}/context.jsonl — may include compaction summaries) [2] Current Message (with timestamp, username, attachments) ``` @@ -627,26 +627,29 @@ Config primitives: - `ingressBindings`: message entrypoints (`slash_command`, `app_mention`, `thread_reply`, `direct_message`). - `proactiveTriggers`: scheduler-owned trigger definitions (`cron`, `heartbeat`) with session mode and delivery target. - `deliveryDefaults`: fallback delivery when a specific trigger does not override delivery. -- `capabilities`: connectors and tool policy. +- `useCapabilities`: capability selections + resource bindings + derived tool policy. - `policy`: access constraints and quiet hours. ```json { - "capabilities": { - "connectors": [ - { - "id": "primary-duckdb", - "kind": "duckdb", - "config": { - "path": "/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb" - } + "resources": [ + { + "id": "primary-duckdb", + "kind": "duckdb", + "path": "/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb" + } + ], + "useCapabilities": [ + { + "capability": "query-gravity-v1" + }, + { + "capability": "duckdb-analyst-v1", + "bindResources": { + "warehouse": "primary-duckdb" } - ], - "tools": { - "allow": ["read", "bash"], - "deny": [] } - }, + ], "ingressBindings": [ { "id": "slack-wiggs-slash", @@ -857,11 +860,11 @@ What it writes directly: Configuration: -- Ingress binding: `gravity.agents.config.ingressBindings` (surface + entrypoint + session mode) -- Proactive triggers: `gravity.agents.config.proactiveTriggers` (`cron` + `heartbeat`) -- Delivery defaults: `gravity.agents.config.deliveryDefaults` -- Connector/tool policy: `gravity.agents.config.capabilities` -- Model selection: `gravity.agents.model` (single source of truth for MVP) +- Ingress binding: compiled code-defined listener declarations (`defineAgent(...).listen`) +- Proactive triggers: compiled code-defined trigger declarations (`defineAgent(...).proactive`) +- Delivery defaults: code-defined runtime declarations (agent-level override + framework defaults) +- Connector/tool policy: code-defined declarations (no runtime dependence on `gravity.agents.config` behavior blobs) +- Model selection: code-defined declaration with framework fallback - Skills loading: `store/shared/skills/` (global) + `store/agents/{agent-id}/skills/` (agent-specific) - Memory loading: `store/agents/{agent-id}/memory/` - Scratch workspace: `workspace/{agent-id}/` @@ -969,7 +972,7 @@ Each checkpoint produces a verifiable working state. Evaluate before moving on ### CP5: Run logging + store conventions verified - [ ] Runs logged to `gravity.runs` after each interaction (log-run skill working) -- [ ] `store/` directory conventions solid (shared/, agents/, connectors/) +- [ ] `store/` directory conventions solid (shared/, agents/, resources/) - [ ] Git repo initialized for `store/` - [ ] `query-gravity.md` skill working — agent can introspect its own config and history - [ ] `rollback.md` skill working — agent can revert a skill change via git @@ -977,8 +980,23 @@ Each checkpoint produces a verifiable working state. Evaluate before moving on **Eval:** "Is the state ownership split (Postgres vs files) working as designed? Can the agent see itself in the system?" +### CP5.1: Rearchitecture parity (code-defined control plane) + +- [ ] `defineConfig(...)` + `defineAgent(...)` contracts in place for code-defined agents +- [ ] Runtime declarations compiled from code (routing, proactive triggers, session defaults) +- [ ] Slack slash/message/proactive execution paths consume compiled declarations (not JSONB config resolvers) +- [ ] Single executor seam is enforced for all tool calls (`Executor` + per-agent runtime selection) +- [ ] Host executor remains default for MVP while sandbox executor is scaffolded but disabled by default +- [ ] Legacy JSONB-driven runtime modules removed after parity checks +- [ ] Parity proof remains green (`npm run verify:cp5`, `npm run verify:cp10`, and `npm run check`) +- [ ] Stable IDs (`runId`, `agentId`, `sessionKey`) preserved through the cutover + +**Eval:** "Did we simplify the runtime without regressions? Can new engineers follow one clear architecture path?" + ### CP6: Sessions + memory scaffolding +_Status: on hold until CP5.1 rearchitecture parity is complete._ + - [ ] Dual-history system: `log.jsonl` (permanent, append-only) + `context.jsonl` (compactable) per thread - [ ] Thread-based sessions working (different threads = different context windows) - [ ] MEMORY.md loaded into system prompt each turn @@ -1177,6 +1195,14 @@ Security invariant: no side effects occur without passing policy gates first. ### Security roadmap (post-demo) +**Phase 0 — Executor boundary scaffold (during CP5.1 rearchitecture, pre-demo)** + +All tool calls route through a single executor interface with per-agent runtime selection, but still execute locally by default. + +- Host executor remains the active MVP runtime. +- Sandbox executor wiring exists as a disabled path (no isolation guarantees yet). +- This phase is architecture scaffolding only, not production security enforcement. + **Phase 1 — Tool policy (weeks 1-2, config only, no infrastructure)** Per-agent allow/deny lists for tools. Defined in agent config in `gravity.agents`, enforced before any tool executes. diff --git a/package.json b/package.json index 143212c..30c9021 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "tsx src/index.ts", "build": "tsc -p tsconfig.json", - "start": "node dist/index.js", + "start": "node dist/src/index.js", "typecheck": "tsc --noEmit -p tsconfig.json", "test:unit": "vitest run", "test:unit:watch": "vitest", diff --git a/scripts/test-invariants.mjs b/scripts/test-invariants.mjs index 647d0c0..d183379 100644 --- a/scripts/test-invariants.mjs +++ b/scripts/test-invariants.mjs @@ -410,7 +410,7 @@ function collectNestedGitDirs(startDir, relativePrefix = "") { function invariantStoreConventions() { const requiredDirectories = [ "store/shared/skills", - "store/shared/connectors", + "store/shared/resources", "store/shared/knowledge", "store/agents", ]; @@ -421,6 +421,8 @@ function invariantStoreConventions() { } const requiredSharedSkills = [ + "store/shared/skills/duckdb-query.md", + "store/shared/skills/knowledge-docs-review.md", "store/shared/skills/log-run.md", "store/shared/skills/query-gravity.md", "store/shared/skills/rollback.md", diff --git a/scripts/verify-cp10.ts b/scripts/verify-cp10.ts index a2472e1..4456c09 100644 --- a/scripts/verify-cp10.ts +++ b/scripts/verify-cp10.ts @@ -3,7 +3,6 @@ import { createDb, destroyDb, gravitySchema, - type GravityDatabase, } from "../src/runtime/db.js"; import { createKyselyRunLogRepository, @@ -16,17 +15,13 @@ import { import { createProactiveTriggerScheduler, type ProactiveTriggerFireEvent, + type ResolvedProactiveTrigger, } from "../src/runtime/proactive-trigger-scheduler.js"; const TARGET_AGENT_ID = "data-analyst"; const VERIFY_HEARTBEAT_TRIGGER_ID = "cp10-verify-heartbeat"; const VERIFY_CRON_TRIGGER_ID = "cp10-verify-cron"; -type AgentRow = { - id: string; - config: Record; -}; - type PersistedRunRow = { source_event_id: string | null; trigger_kind: "cron" | "heartbeat" | "message" | "system"; @@ -48,78 +43,74 @@ function assert(condition: unknown, message: string): asserts condition { } } -function buildVerificationConfig(input: { - base: Record; - quietHoursEnabled: boolean; -}): Record { - const quietHours = input.quietHoursEnabled +function buildVerificationTriggers( + quietHoursEnabled: boolean, +): ReadonlyArray { + const quietHours = quietHoursEnabled ? { - enabled: true, timezone: "UTC", startHour: 0, endHour: 0, } : undefined; - return { - ...input.base, - proactiveTriggers: [ - { - id: VERIFY_CRON_TRIGGER_ID, - kind: "cron", - schedule: "*/5 * * * *", - prompt: "CP10 verification cron trigger", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C_CP10_VERIFY", - }, - enabled: true, + return [ + { + agentId: TARGET_AGENT_ID, + triggerId: VERIFY_CRON_TRIGGER_ID, + kind: "cron", + schedule: "*/5 * * * *", + prompt: "CP10 verification cron trigger", + sessionMode: "isolated", + delivery: { + surface: "slack", + mode: "channel_thread", + channelId: "C_CP10_VERIFY", }, - { - id: VERIFY_HEARTBEAT_TRIGGER_ID, - kind: "heartbeat", - intervalSeconds: 300, - prompt: "CP10 verification heartbeat trigger", - sessionMode: "main", - delivery: { - surface: "slack", - mode: "dm", - userId: "U_CP10_VERIFY", - }, - enabled: true, + ...(quietHours ? { quietHours } : {}), + }, + { + agentId: TARGET_AGENT_ID, + triggerId: VERIFY_HEARTBEAT_TRIGGER_ID, + kind: "heartbeat", + intervalSeconds: 300, + prompt: "CP10 verification heartbeat trigger", + sessionMode: "main", + delivery: { + surface: "slack", + mode: "dm", + userId: "U_CP10_VERIFY", }, - ], - policy: quietHours ? { quietHours } : {}, - }; + ...(quietHours ? { quietHours } : {}), + }, + ]; } -async function loadAgentRow( +async function ensureTargetAgentExists( db: ReturnType, -): Promise { +): Promise { const row = await gravitySchema(db) .selectFrom("agents") - .select(["id", "config"]) + .select(["id"]) .where("id", "=", TARGET_AGENT_ID) .executeTakeFirst(); if (!row) { throw new Error(`Agent ${TARGET_AGENT_ID} not found`); } - - return row as AgentRow; } -async function updateAgentConfig( +async function isTargetAgentActive( db: ReturnType, - config: Record, -): Promise { - await gravitySchema(db) - .updateTable("agents") - .set({ config }) +): Promise { + const row = await gravitySchema(db) + .selectFrom("agents") + .select(["id"]) .where("id", "=", TARGET_AGENT_ID) + .where("status", "=", "active") .executeTakeFirst(); + + return Boolean(row); } function buildProactiveSourceEventId(triggerId: string, firedAt: Date): string { @@ -246,10 +237,10 @@ async function main(): Promise { ); const db = createDb(databaseUrl); - const baselineAgent = await loadAgentRow(db); - const baselineConfig = baselineAgent.config; + await ensureTargetAgentExists(db); let currentNow = new Date("2026-02-18T10:20:00.000Z"); + let quietHoursEnabled = false; const capturedEvents: ProactiveTriggerFireEvent[] = []; const knownSourceEventIds = new Set(); @@ -280,16 +271,16 @@ async function main(): Promise { firedAt: replaySeedTimes[1], }); - await updateAgentConfig( - db, - buildVerificationConfig({ - base: baselineConfig, - quietHoursEnabled: false, - }), - ); - const scheduler = createProactiveTriggerScheduler({ db, + loadTriggers: async (runtimeDb) => { + const active = await isTargetAgentActive(runtimeDb); + if (!active) { + return []; + } + + return buildVerificationTriggers(quietHoursEnabled); + }, now: () => new Date(currentNow), enableReplay: true, replayLookbackHours: 6, @@ -326,13 +317,7 @@ async function main(): Promise { "Expected dm replay delivery", ); - await updateAgentConfig( - db, - buildVerificationConfig({ - base: baselineConfig, - quietHoursEnabled: true, - }), - ); + quietHoursEnabled = true; await scheduler.reload(); currentNow = new Date("2026-02-18T10:40:00.000Z"); @@ -410,7 +395,6 @@ async function main(): Promise { `[cp10] verification passed (replay=${replayEvents.length}, manual=1, quiet_hours_suppressed=true)`, ); } finally { - await updateAgentConfig(db, baselineConfig); await destroyDb(db); } } diff --git a/scripts/verify-cp5.ts b/scripts/verify-cp5.ts index eb95cc6..5ddd5fb 100644 --- a/scripts/verify-cp5.ts +++ b/scripts/verify-cp5.ts @@ -54,7 +54,7 @@ function assert(condition: unknown, message: string): asserts condition { function assertStoreConventions(repoRoot: string): void { const requiredDirectories = [ "store/shared/skills", - "store/shared/connectors", + "store/shared/resources", "store/shared/knowledge", "store/agents/data-analyst/skills", "store/agents/data-analyst/memory", @@ -62,6 +62,8 @@ function assertStoreConventions(repoRoot: string): void { "store/agents/compliance-helper/memory", ]; const requiredFiles = [ + "store/shared/skills/duckdb-query.md", + "store/shared/skills/knowledge-docs-review.md", "store/shared/skills/log-run.md", "store/shared/skills/query-gravity.md", "store/shared/skills/rollback.md", diff --git a/src/index.ts b/src/index.ts index f4ae408..817bdb5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,22 @@ import process from "node:process"; +import { + agentRegistry, + compiledDeclarations, + type CompiledMessageEntrypoint, + type CompiledMessageListener, + type CompiledTriggerDimensions, +} from "../agents/index.js"; import { loadConfig } from "./runtime/config.js"; -import { parseAgentConfig } from "./runtime/agent-config.js"; import { createDb, destroyDb, gravitySchema } from "./runtime/db.js"; import { createEventIdempotencyGuard, createKyselyEventIdempotencyRepository, type EventIdempotencyGuard, } from "./runtime/event-idempotency.js"; +import { + createExecutorManager, + type ExecutorRuntime, +} from "./runtime/executor-manager.js"; import { runPiAgentTurn, summarizeAgentResponseForRunLog, @@ -14,13 +24,18 @@ import { } from "./runtime/pi-agent-runner.js"; import { createProactiveTriggerScheduler, + type ProactiveQuietHours, type ProactiveTriggerFireEvent, type ProactiveTriggerScheduler, + type ResolvedProactiveTrigger, } from "./runtime/proactive-trigger-scheduler.js"; import { composeRunLifecycleLoggers, createConsoleRunLifecycleLogger, createRunContext, + type RunEntrypoint, + type RunSurface, + type RunTriggerKind, withRunLifecycle, } from "./runtime/run-lifecycle.js"; import { @@ -29,10 +44,11 @@ import { type RunLogStore, } from "./runtime/run-log-store.js"; import { - resolveMessageIngress, - type ActiveAgentIngressRow, - type MessageEntrypoint, -} from "./runtime/ingress-binding-resolver.js"; + buildIsolatedSessionKey, + buildMessageSessionKey, + buildProactiveSessionKey, + buildSlashSessionKey, +} from "./runtime/session-key.js"; import { createKyselySessionCatalogRepository, createSessionCatalog, @@ -42,26 +58,25 @@ import { import { type InboundSlackMessage, type InboundSlackSlashCommand, + normalizeSlashCommand, type SlackSlashCommandAckResponse, SlackTransport, } from "./runtime/slack-transport.js"; -import { - createDefaultSlashCommandAgentMap, - resolveAgentIdForSlashCommand, -} from "./runtime/slash-command-router.js"; -import { - normalizeProactiveTrigger, - normalizeSlackMessageTrigger, - normalizeSlackSlashCommandTrigger, - normalizeSystemTrigger, - type NormalizedTrigger, -} from "./runtime/trigger-normalizer.js"; +type NormalizedTrigger = { + triggerKind: RunTriggerKind; + surface: RunSurface; + entrypoint: RunEntrypoint; +}; process.loadEnvFile(); const lifecycleLogger = createConsoleRunLifecycleLogger(); -const bootstrapTrigger = normalizeSystemTrigger(); +const bootstrapTrigger: NormalizedTrigger = { + triggerKind: "system", + surface: "system", + entrypoint: "system", +}; const bootstrapRunContext = createRunContext({ agentId: "system-bootstrap", sessionKey: "system-bootstrap:main", @@ -122,19 +137,27 @@ process.on("SIGTERM", () => { void shutdown("SIGTERM"); }); -const slashCommandAgentMap = createDefaultSlashCommandAgentMap(); const enableSlackMessageEvents = true; +const executorManager = createExecutorManager({ enableSandbox: false }); + +type MessageEntrypoint = CompiledMessageEntrypoint; function logDebug(event: string, payload: Record): void { console.log(`[gravity][debug] ${event} ${JSON.stringify(payload)}`); } -function createSlashRunId(sourceEventId: string): string { - return `slack:${sourceEventId}`; +function toNormalizedTrigger( + dimensions: CompiledTriggerDimensions, +): NormalizedTrigger { + return { + triggerKind: dimensions.triggerKind, + surface: dimensions.surface, + entrypoint: dimensions.entrypoint, + }; } -function createSlashSessionKey(agentId: string, sourceEventId: string): string { - return `${agentId}:${sourceEventId}`; +function createSlashRunId(sourceEventId: string): string { + return `slack:${sourceEventId}`; } function createMessageRunId(sourceEventId: string): string { @@ -145,41 +168,9 @@ function createProactiveRunId(sourceEventId: string): string { return sourceEventId; } -function createMessageSessionKey(input: { - agentId: string; - message: InboundSlackMessage; - sessionMode: SessionMode; -}): string { - if (input.sessionMode === "main") { - return `${input.agentId}:main`; - } - - if (input.sessionMode === "isolated") { - return `${input.agentId}:${input.message.sourceEventId}`; - } - - if (input.message.isDirectMessage) { - return `${input.agentId}:${input.message.channelId}`; - } - - return `${input.agentId}:${input.message.threadTs}`; -} - -function createProactiveSessionKey(input: { - agentId: string; - triggerId: string; - sourceEventId: string; - sessionMode: SessionMode; -}): string { - if (input.sessionMode === "main") { - return `${input.agentId}:main`; - } - - if (input.sessionMode === "thread") { - return `${input.agentId}:proactive:${input.triggerId}:thread`; - } - - return `${input.agentId}:proactive:${input.triggerId}:${input.sourceEventId}`; +function resolveAgentRuntimePolicy(agentId: string): ExecutorRuntime { + const runtime = agentRegistry.agentsById.get(agentId)?.runtime; + return runtime ?? "host"; } async function tryAcquireSourceEventLease( @@ -281,9 +272,11 @@ async function executeAgentRun( const runResult = await runPiAgentTurn({ db: input.db, agentId: input.agentId, + agentRuntime: resolveAgentRuntimePolicy(input.agentId), sessionKey: input.sessionKey, prompt: input.prompt, anthropicApiKey, + executorManager, }); const responseText = runResult.responseText; @@ -320,7 +313,9 @@ function buildUnmappedSlashCommandEchoResponse( type SlashCommandDecision = { agentId: string | null; + sessionMode: SessionMode | null; query: string; + trigger: NormalizedTrigger | null; ackResponse: SlackSlashCommandAckResponse; manualWake: { triggerId?: string; @@ -331,6 +326,7 @@ type MessageDecision = { agentId: string | null; entrypoint: MessageEntrypoint | null; sessionMode: SessionMode | null; + trigger: NormalizedTrigger | null; sessionKeyOverride: string | null; query: string; route: "binding" | "unmapped"; @@ -340,18 +336,22 @@ function resolveSlashCommandDecision( command: InboundSlackSlashCommand, ): SlashCommandDecision { const query = buildSlashCommandQuery(command); - const agentId = resolveAgentIdForSlashCommand( - command.command, - slashCommandAgentMap, - ); - if (!agentId) { + const compiledSlashListener = + compiledDeclarations.ingress.slashCommands[ + normalizeSlashCommand(command.command) + ]; + if (!compiledSlashListener) { return { agentId: null, + sessionMode: null, query, + trigger: null, ackResponse: buildUnmappedSlashCommandEchoResponse(command), manualWake: null, }; } + const agentId = compiledSlashListener.agentId; + const trigger = toNormalizedTrigger(compiledSlashListener.trigger); const normalizedText = command.text.trim(); const wakeTokens = normalizedText.split(/\s+/).filter((token) => token.length > 0); @@ -369,7 +369,9 @@ function resolveSlashCommandDecision( : " all heartbeat triggers"; return { agentId, + sessionMode: compiledSlashListener.sessionMode, query, + trigger, manualWake, ackResponse: { response_type: "ephemeral", @@ -380,7 +382,9 @@ function resolveSlashCommandDecision( return { agentId, + sessionMode: compiledSlashListener.sessionMode, query, + trigger, ackResponse: buildSlashCommandEchoResponse(command, agentId), manualWake: null, }; @@ -396,23 +400,244 @@ function buildMessageQuery(message: InboundSlackMessage): string { return message.text.trim(); } -async function loadActiveAgentIngressRows( +async function loadActiveAgentChannels( + dbClient: ReturnType, +): Promise> { + const rows = await gravitySchema(dbClient) + .selectFrom("agents") + .select(["id", "channel_id"]) + .where("status", "=", "active") + .execute(); + + const byAgentId = new Map(); + for (const row of rows) { + byAgentId.set(row.id, row.channel_id); + } + return byAgentId; +} + +async function loadActiveAgentIds( dbClient: ReturnType, -): Promise { +): Promise> { const rows = await gravitySchema(dbClient) .selectFrom("agents") - .select(["id", "channel_id", "config"]) + .select(["id"]) .where("status", "=", "active") .execute(); - return rows.map((row) => ({ - id: row.id, - channel_id: row.channel_id, - config: parseAgentConfig(row.config, { - warn: console.warn, - context: `agentId=${row.id}`, - }), - })); + return new Set(rows.map((row) => row.id)); +} + +function toProactiveQuietHours( + quietHours: typeof compiledDeclarations.proactive.triggers[number]["quietHours"], +): ProactiveQuietHours | undefined { + if (!quietHours || quietHours.enabled === false) { + return undefined; + } + + return { + timezone: quietHours.timezone, + startHour: quietHours.startHour, + endHour: quietHours.endHour, + ...(quietHours.daysOfWeek + ? { + daysOfWeek: [...quietHours.daysOfWeek], + } + : {}), + }; +} + +function compileProactiveTriggersForActiveAgents( + activeAgentIds: ReadonlySet, +): ResolvedProactiveTrigger[] { + const triggers: ResolvedProactiveTrigger[] = []; + for (const trigger of compiledDeclarations.proactive.triggers) { + if (!activeAgentIds.has(trigger.agentId)) { + continue; + } + + const quietHours = toProactiveQuietHours(trigger.quietHours); + if (trigger.kind === "cron") { + triggers.push({ + agentId: trigger.agentId, + triggerId: trigger.triggerId, + kind: "cron", + schedule: trigger.schedule, + prompt: trigger.prompt, + sessionMode: trigger.sessionMode, + delivery: trigger.delivery, + ...(quietHours ? { quietHours } : {}), + }); + continue; + } + + triggers.push({ + agentId: trigger.agentId, + triggerId: trigger.triggerId, + kind: "heartbeat", + intervalSeconds: trigger.intervalSeconds, + prompt: trigger.prompt, + sessionMode: trigger.sessionMode, + delivery: trigger.delivery, + ...(quietHours ? { quietHours } : {}), + }); + } + + return triggers; +} + +async function loadActiveCompiledProactiveTriggers( + dbClient: ReturnType, +): Promise> { + const activeAgentIds = await loadActiveAgentIds(dbClient); + return compileProactiveTriggersForActiveAgents(activeAgentIds); +} + +function deriveMessageEntrypoint( + message: InboundSlackMessage, +): MessageEntrypoint | null { + if (message.surface === "app_mention") { + return "app_mention"; + } + + // DM thread replies are treated as direct_message so channel-owned main sessions + // keep handling follow-ups even when users start Slack thread UI off bot messages. + if (message.isDirectMessage) { + return "direct_message"; + } + + if (message.threadTs !== message.messageTs) { + return "thread_reply"; + } + + return null; +} + +function messageListenerMatches(input: { + listener: CompiledMessageListener; + message: InboundSlackMessage; + entrypoint: MessageEntrypoint; + threadOwnerAgentId: string | null; +}): boolean { + const { listener, message, entrypoint, threadOwnerAgentId } = input; + + if (listener.entrypoint !== entrypoint) { + return false; + } + + const match = listener.match; + if (!match) { + return true; + } + + const matchChannelId = match.channelId; + if (matchChannelId && matchChannelId !== message.channelId) { + return false; + } + + const matchUserId = match.userId; + if (matchUserId && matchUserId !== message.userId) { + return false; + } + + const matchIsDirectMessage = match.isDirectMessage; + if ( + matchIsDirectMessage !== undefined && + matchIsDirectMessage !== message.isDirectMessage + ) { + return false; + } + + const matchThreadOwnedByAgent = match.threadOwnedByAgent; + if (matchThreadOwnedByAgent === true && entrypoint !== "thread_reply") { + return false; + } + if (matchThreadOwnedByAgent === true) { + return threadOwnerAgentId === listener.agentId; + } + if (matchThreadOwnedByAgent === false && threadOwnerAgentId === listener.agentId) { + return false; + } + + return true; +} + +type ResolvedCompiledMessageIngress = { + agentId: string; + entrypoint: MessageEntrypoint; + sessionMode: SessionMode; + trigger: NormalizedTrigger; + route: "binding"; +}; + +function resolveMessageIngressFromDeclarations(input: { + message: InboundSlackMessage; + activeAgentChannels: ReadonlyMap; + threadOwnerAgentId: string | null; +}): ResolvedCompiledMessageIngress | null { + const entrypoint = deriveMessageEntrypoint(input.message); + if (!entrypoint) { + return null; + } + + const listeners = compiledDeclarations.ingress.messageByEntrypoint[entrypoint]; + const candidates: Array< + ResolvedCompiledMessageIngress & { + channelAffinityScore: number; + } + > = []; + + for (const listener of listeners) { + const channelAffinity = input.activeAgentChannels.get(listener.agentId); + if (channelAffinity === undefined) { + continue; + } + + if ( + !messageListenerMatches({ + listener, + message: input.message, + entrypoint, + threadOwnerAgentId: input.threadOwnerAgentId, + }) + ) { + continue; + } + + candidates.push({ + agentId: listener.agentId, + entrypoint, + sessionMode: listener.sessionMode, + trigger: toNormalizedTrigger(listener.trigger), + route: "binding", + channelAffinityScore: + channelAffinity === input.message.channelId ? 1 : 0, + }); + } + + if (candidates.length === 0) { + return null; + } + + candidates.sort((a, b) => { + if (a.channelAffinityScore !== b.channelAffinityScore) { + return b.channelAffinityScore - a.channelAffinityScore; + } + return a.agentId.localeCompare(b.agentId); + }); + + const winner = candidates[0]; + if (!winner) { + return null; + } + + return { + agentId: winner.agentId, + entrypoint: winner.entrypoint, + sessionMode: winner.sessionMode, + trigger: winner.trigger, + route: winner.route, + }; } type ActiveThreadSession = { @@ -450,6 +675,7 @@ async function resolveMessageDecision( agentId: null, entrypoint: null, sessionMode: null, + trigger: null, sessionKeyOverride: null, query, route: "unmapped", @@ -461,8 +687,10 @@ async function resolveMessageDecision( ? await findActiveThreadSession(dbClient, message.channelId, message.threadTs) : null; - const activeAgents = await loadActiveAgentIngressRows(dbClient); - const resolved = resolveMessageIngress(message, activeAgents, { + const activeAgents = await loadActiveAgentChannels(dbClient); + const resolved = resolveMessageIngressFromDeclarations({ + message, + activeAgentChannels: activeAgents, threadOwnerAgentId: activeThreadSession?.agent_id ?? null, }); @@ -476,7 +704,7 @@ async function resolveMessageDecision( isDirectMessage: message.isDirectMessage, activeThreadSessionKey: activeThreadSession?.session_key ?? null, activeThreadOwnerAgentId: activeThreadSession?.agent_id ?? null, - activeAgentCount: activeAgents.length, + activeAgentCount: activeAgents.size, resolvedAgentId: resolved?.agentId ?? null, resolvedEntrypoint: resolved?.entrypoint ?? null, resolvedSessionMode: resolved?.sessionMode ?? null, @@ -488,6 +716,7 @@ async function resolveMessageDecision( agentId: null, entrypoint: null, sessionMode: null, + trigger: null, sessionKeyOverride: null, query, route: "unmapped", @@ -503,6 +732,7 @@ async function resolveMessageDecision( agentId: resolved.agentId, entrypoint: resolved.entrypoint, sessionMode: resolved.sessionMode, + trigger: resolved.trigger, sessionKeyOverride, query, route: resolved.route, @@ -624,9 +854,13 @@ async function handleProactiveTrigger( } try { - const normalizedTrigger = normalizeProactiveTrigger(event.kind); + const normalizedTrigger: NormalizedTrigger = { + triggerKind: event.trigger.triggerKind, + surface: event.trigger.surface, + entrypoint: event.trigger.entrypoint, + }; const runId = createProactiveRunId(event.sourceEventId); - const sessionKey = createProactiveSessionKey({ + const sessionKey = buildProactiveSessionKey({ agentId: event.agentId, triggerId: event.triggerId, sourceEventId: event.sourceEventId, @@ -731,7 +965,7 @@ async function handleInboundSlashCommand( ackResponseType: decision.ackResponse.response_type, }); - if (!decision.agentId) { + if (!decision.agentId || !decision.trigger) { console.log( `[gravity] slash command ignored (unmapped command=${command.command} sourceEventId=${command.sourceEventId})`, ); @@ -763,16 +997,25 @@ async function handleInboundSlashCommand( const activeRunLogStore = runLogStore; const activeSlackTransport = slackTransport; const activeAgentId = decision.agentId; + const resolvedSessionMode = decision.sessionMode; const activeSessionCatalog = sessionCatalog; - const normalizedTrigger = normalizeSlackSlashCommandTrigger(); + const normalizedTrigger = decision.trigger; const activeScheduler = proactiveTriggerScheduler; + if (!resolvedSessionMode) { + throw new Error( + `Slash command ${command.command} missing compiled session mode for agent ${activeAgentId}`, + ); + } + try { if (decision.manualWake) { const manualWakeDecision = decision.manualWake; const runId = createSlashRunId(command.sourceEventId); - const sessionKey = createSlashSessionKey(activeAgentId, command.sourceEventId); - const normalizedTrigger = normalizeSlackSlashCommandTrigger(); + const sessionKey = buildIsolatedSessionKey( + activeAgentId, + command.sourceEventId, + ); let resultSummary = "manual wake requested"; const persistenceLogger = activeRunLogStore.createLifecycleLogger({ query: decision.query, @@ -832,7 +1075,6 @@ async function handleInboundSlashCommand( } const runId = createSlashRunId(command.sourceEventId); - const sessionKey = createSlashSessionKey(activeAgentId, command.sourceEventId); const fullPrompt = command.text.trim(); const threadRootText = [ `Running ${command.command} for <@${command.userId}>. Replying in thread.`, @@ -843,6 +1085,13 @@ async function handleInboundSlashCommand( command.channelId, threadRootText, ); + const sessionKey = buildSlashSessionKey({ + agentId: activeAgentId, + channelId: command.channelId, + threadTs, + sourceEventId: command.sourceEventId, + sessionMode: resolvedSessionMode, + }); logDebug("slash.session.init", { sourceEventId: command.sourceEventId, @@ -851,13 +1100,14 @@ async function handleInboundSlashCommand( channelId: command.channelId, threadTs, userId: command.userId, + sessionMode: resolvedSessionMode, }); await ensureActiveSlackSession({ catalog: activeSessionCatalog, sessionKey, agentId: activeAgentId, - mode: "thread", + mode: resolvedSessionMode, channelId: command.channelId, threadTs, ownerUserId: command.userId, @@ -881,6 +1131,7 @@ async function handleInboundSlashCommand( policyDecisions: { trigger: "slash_command", response_type: decision.ackResponse.response_type, + session_mode: resolvedSessionMode, }, }, onResponse: async (responseText) => { @@ -893,7 +1144,7 @@ async function handleInboundSlashCommand( catalog: activeSessionCatalog, sessionKey, agentId: activeAgentId, - mode: "thread", + mode: resolvedSessionMode, channelId: command.channelId, threadTs, ownerUserId: command.userId, @@ -967,7 +1218,12 @@ async function handleInboundMessage(message: InboundSlackMessage): Promise route: decision.route, }); - if (!decision.agentId || !decision.entrypoint || !decision.sessionMode) { + if ( + !decision.agentId || + !decision.entrypoint || + !decision.sessionMode || + !decision.trigger + ) { console.log( `[gravity] message ignored (surface=${message.surface} channelId=${message.channelId} sourceEventId=${message.sourceEventId})`, ); @@ -1001,16 +1257,19 @@ async function handleInboundMessage(message: InboundSlackMessage): Promise const resolvedAgentId = decision.agentId; const resolvedEntrypoint = decision.entrypoint; const resolvedSessionMode = decision.sessionMode; - const normalizedTrigger = normalizeSlackMessageTrigger(resolvedEntrypoint); + const normalizedTrigger = decision.trigger; try { const runId = createMessageRunId(message.sourceEventId); const sessionKey = decision.sessionKeyOverride ?? - createMessageSessionKey({ + buildMessageSessionKey({ agentId: resolvedAgentId, - message, + channelId: message.channelId, + threadTs: message.threadTs, + sourceEventId: message.sourceEventId, sessionMode: resolvedSessionMode, + isDirectMessage: message.isDirectMessage, }); logDebug("message.session.bind", { @@ -1133,6 +1392,7 @@ try { proactiveTriggerScheduler = createProactiveTriggerScheduler({ db: dbClient, + loadTriggers: loadActiveCompiledProactiveTriggers, onTrigger: handleProactiveTrigger, }); await proactiveTriggerScheduler.start(); diff --git a/src/resources/duckdb/plugin.ts b/src/resources/duckdb/plugin.ts new file mode 100644 index 0000000..68fb46e --- /dev/null +++ b/src/resources/duckdb/plugin.ts @@ -0,0 +1,144 @@ +import { readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import { + readMarkdownFiles, + readOptionalFile, + resolvePathFromCwd, +} from "../fs-utils.js"; +import type { ResourceDocument, ResourcePlugin } from "../types.js"; + +const MAX_DBT_CONTEXT_FILES = 10; +const MAX_DBT_FILE_CHARS = 6000; + +function isDbtMetadataFile(fileName: string): boolean { + return ( + fileName.endsWith(".yml") || + fileName.endsWith(".yaml") || + fileName.endsWith(".md") + ); +} + +async function walkDbtMetadataFiles(modelsDir: string): Promise { + const discoveredFiles: string[] = []; + const stack = [modelsDir]; + + while (stack.length > 0) { + const nextDir = stack.pop(); + if (!nextDir) { + continue; + } + + let entries: Array<{ + isDirectory: () => boolean; + isFile: () => boolean; + name: string; + }>; + try { + const dirEntries = await readdir(nextDir, { + withFileTypes: true, + encoding: "utf8", + }); + entries = dirEntries; + } catch { + continue; + } + + const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of sortedEntries) { + const entryPath = path.join(nextDir, entry.name); + if (entry.isDirectory()) { + stack.push(entryPath); + continue; + } + + if (!entry.isFile()) { + continue; + } + + if (isDbtMetadataFile(entry.name)) { + discoveredFiles.push(entryPath); + } + } + } + + discoveredFiles.sort(); + return discoveredFiles.slice(0, MAX_DBT_CONTEXT_FILES); +} + +async function loadDbtContextDocs(input: { + cwd: string; + resourcePath: string; +}): Promise { + const resolvedResourcePath = resolvePathFromCwd(input.cwd, input.resourcePath); + const projectRoot = path.dirname(resolvedResourcePath); + const modelsDir = path.join(projectRoot, "models"); + + try { + const modelsDirStats = await stat(modelsDir); + if (!modelsDirStats.isDirectory()) { + return []; + } + } catch { + return []; + } + + const metadataFilePaths = await walkDbtMetadataFiles(modelsDir); + const contextDocs: ResourceDocument[] = []; + + for (const metadataFilePath of metadataFilePaths) { + const content = await readOptionalFile(metadataFilePath); + if (!content) { + continue; + } + + contextDocs.push({ + filePath: metadataFilePath, + content: content.slice(0, MAX_DBT_FILE_CHARS).trim(), + }); + } + + return contextDocs; +} + +async function loadSharedResourceDocs(input: { + cwd: string; + sharedRoot: string; +}): Promise { + const sharedResourcesDir = path.join( + resolvePathFromCwd(input.cwd, input.sharedRoot), + "resources", + ); + const docs = await readMarkdownFiles(sharedResourcesDir); + return docs.filter((document) => + path.basename(document.filePath).startsWith("duckdb"), + ); +} + +export const duckdbResourcePlugin = { + kind: "duckdb", + async load(input) { + const [sharedDocs, contextDocs] = await Promise.all([ + loadSharedResourceDocs({ + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }), + loadDbtContextDocs({ + cwd: input.cwd, + resourcePath: input.spec.path, + }), + ]); + + return { + resourceId: input.spec.id, + resourceKind: "duckdb", + guidance: [ + `- Resource \`${input.spec.id}\` (\`duckdb\`): query structured data via SQL when facts need validation.`, + `- Preferred DuckDB command pattern: duckdb ${input.spec.path} -cmd \"\"`, + "- Use `bash` for SQL execution and `read` for schema/docs inspection.", + "- If command output is truncated, follow truncation hints or rerun narrower SQL.", + ], + sharedDocs, + contextDocs, + }; + }, +} satisfies ResourcePlugin<"duckdb">; diff --git a/src/resources/fs-utils.ts b/src/resources/fs-utils.ts new file mode 100644 index 0000000..cb33114 --- /dev/null +++ b/src/resources/fs-utils.ts @@ -0,0 +1,60 @@ +import { readdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import type { ResourceDocument } from "./types.js"; + +export function resolvePathFromCwd(cwd: string, inputPath: string): string { + if (path.isAbsolute(inputPath)) { + return inputPath; + } + + return path.resolve(cwd, inputPath); +} + +export async function readMarkdownFiles(dirPath: string): Promise { + try { + const directoryEntries = await readdir(dirPath, { withFileTypes: true }); + const markdownFiles = directoryEntries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) + .map((entry) => entry.name) + .sort(); + + const loadedFiles = await Promise.all( + markdownFiles.map(async (fileName) => { + const filePath = path.join(dirPath, fileName); + const content = await readFile(filePath, "utf8"); + return { + filePath, + content: content.trim(), + } satisfies ResourceDocument; + }), + ); + + return loadedFiles.filter((document) => document.content.length > 0); + } catch { + return []; + } +} + +export async function readOptionalFile(filePath: string): Promise { + try { + const content = await readFile(filePath, "utf8"); + const trimmed = content.trim(); + return trimmed.length > 0 ? trimmed : null; + } catch { + return null; + } +} + +export async function readRequiredMarkdownFile( + filePath: string, +): Promise { + const content = await readOptionalFile(filePath); + if (!content) { + throw new Error(`Required markdown file is missing or empty: ${filePath}`); + } + + return { + filePath, + content, + }; +} diff --git a/src/resources/knowledge-docs/plugin.ts b/src/resources/knowledge-docs/plugin.ts new file mode 100644 index 0000000..bab225c --- /dev/null +++ b/src/resources/knowledge-docs/plugin.ts @@ -0,0 +1,37 @@ +import path from "node:path"; +import { readMarkdownFiles, resolvePathFromCwd } from "../fs-utils.js"; +import type { ResourceDocument, ResourcePlugin } from "../types.js"; + +async function loadSharedResourceDocs(input: { + cwd: string; + sharedRoot: string; +}): Promise { + const sharedResourcesDir = path.join( + resolvePathFromCwd(input.cwd, input.sharedRoot), + "resources", + ); + const docs = await readMarkdownFiles(sharedResourcesDir); + return docs.filter((document) => + path.basename(document.filePath).startsWith("knowledge-docs"), + ); +} + +export const knowledgeDocsResourcePlugin = { + kind: "knowledge-docs", + async load(input) { + const sharedDocs = await loadSharedResourceDocs({ + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }); + + return { + resourceId: input.spec.id, + resourceKind: "knowledge-docs", + guidance: [ + `- Resource \`${input.spec.id}\` (\`knowledge-docs\`): use loaded markdown docs for policy/process guidance before final recommendations.`, + ], + sharedDocs, + contextDocs: [], + }; + }, +} satisfies ResourcePlugin<"knowledge-docs">; diff --git a/src/resources/registry.ts b/src/resources/registry.ts new file mode 100644 index 0000000..7c6e698 --- /dev/null +++ b/src/resources/registry.ts @@ -0,0 +1,55 @@ +import type { AgentResource } from "../../agents/contracts.js"; +import { duckdbResourcePlugin } from "./duckdb/plugin.js"; +import { knowledgeDocsResourcePlugin } from "./knowledge-docs/plugin.js"; +import type { + ResourceContribution, + ResourcePlugin, +} from "./types.js"; + +const resourcePlugins = { + duckdb: duckdbResourcePlugin, + "knowledge-docs": knowledgeDocsResourcePlugin, +} satisfies { [K in AgentResource["kind"]]: ResourcePlugin }; + +function loadResourceContribution(input: { + resource: AgentResource; + cwd: string; + sharedRoot: string; +}): Promise { + switch (input.resource.kind) { + case "duckdb": + return resourcePlugins.duckdb.load({ + spec: input.resource, + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }); + case "knowledge-docs": + return resourcePlugins["knowledge-docs"].load({ + spec: input.resource, + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }); + default: { + const exhaustiveResourceKind: never = input.resource; + throw new Error( + `Unhandled resource kind: ${String(exhaustiveResourceKind)}`, + ); + } + } +} + +export async function loadResourceContributions(input: { + resources: readonly AgentResource[]; + cwd: string; + sharedRoot: string; +}): Promise { + return Promise.all( + input.resources.map((resource) => + loadResourceContribution({ + resource, + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }), + ), + ); +} diff --git a/src/resources/types.ts b/src/resources/types.ts new file mode 100644 index 0000000..da4bd25 --- /dev/null +++ b/src/resources/types.ts @@ -0,0 +1,29 @@ +import type { AgentResource } from "../../agents/contracts.js"; + +export type ResourceDocument = Readonly<{ + filePath: string; + content: string; +}>; + +export type ResourceContribution< + TKind extends AgentResource["kind"] = AgentResource["kind"], +> = Readonly<{ + resourceId: string; + resourceKind: TKind; + guidance: readonly string[]; + sharedDocs: readonly ResourceDocument[]; + contextDocs: readonly ResourceDocument[]; +}>; + +export type ResourceLoadInput = Readonly<{ + spec: Extract; + cwd: string; + sharedRoot: string; +}>; + +export type ResourcePlugin< + TKind extends AgentResource["kind"] = AgentResource["kind"], +> = Readonly<{ + kind: TKind; + load: (input: ResourceLoadInput) => Promise>; +}>; diff --git a/src/runtime/agent-config.ts b/src/runtime/agent-config.ts deleted file mode 100644 index 90fb04a..0000000 --- a/src/runtime/agent-config.ts +++ /dev/null @@ -1,467 +0,0 @@ -import { type Static, Type } from "@sinclair/typebox"; -import { Value } from "@sinclair/typebox/value"; - -const SessionModeSchema = Type.Union([ - Type.Literal("thread"), - Type.Literal("main"), - Type.Literal("isolated"), -]); - -const IngressEntrypointSchema = Type.Union([ - Type.Literal("slash_command"), - Type.Literal("app_mention"), - Type.Literal("thread_reply"), - Type.Literal("direct_message"), -]); - -const IngressBindingMatchSchema = Type.Object( - { - command: Type.Optional(Type.String()), - channelId: Type.Optional(Type.String()), - userId: Type.Optional(Type.String()), - isDirectMessage: Type.Optional(Type.Boolean()), - threadOwnedByAgent: Type.Optional(Type.Boolean()), - }, - { additionalProperties: true }, -); - -const IngressBindingSchema = Type.Object( - { - id: Type.Optional(Type.String()), - kind: Type.Literal("message"), - surface: Type.Literal("slack"), - entrypoint: IngressEntrypointSchema, - sessionMode: Type.Optional(SessionModeSchema), - enabled: Type.Optional(Type.Boolean()), - match: Type.Optional(IngressBindingMatchSchema), - }, - { additionalProperties: true }, -); - -const SlackChannelThreadDeliverySchema = Type.Object( - { - surface: Type.Literal("slack"), - mode: Type.Literal("channel_thread"), - channelId: Type.String(), - }, - { additionalProperties: true }, -); - -const SlackDmDeliverySchema = Type.Object( - { - surface: Type.Literal("slack"), - mode: Type.Literal("dm"), - userId: Type.String(), - }, - { additionalProperties: true }, -); - -const DeliveryTargetSchema = Type.Union([ - SlackChannelThreadDeliverySchema, - SlackDmDeliverySchema, -]); - -const QuietHoursSchema = Type.Object( - { - enabled: Type.Optional(Type.Boolean()), - timezone: Type.String(), - startHour: Type.Integer({ minimum: 0, maximum: 23 }), - endHour: Type.Integer({ minimum: 0, maximum: 23 }), - daysOfWeek: Type.Optional( - Type.Array(Type.Integer({ minimum: 0, maximum: 6 }), { - minItems: 1, - maxItems: 7, - }), - ), - }, - { additionalProperties: true }, -); - -const PolicySchema = Type.Object( - { - quietHours: Type.Optional(QuietHoursSchema), - }, - { additionalProperties: true }, -); - -const CronProactiveTriggerSchema = Type.Object( - { - id: Type.String(), - kind: Type.Literal("cron"), - schedule: Type.String(), - prompt: Type.String(), - sessionMode: Type.Optional(SessionModeSchema), - delivery: Type.Optional(DeliveryTargetSchema), - enabled: Type.Optional(Type.Boolean()), - }, - { additionalProperties: true }, -); - -const HeartbeatProactiveTriggerSchema = Type.Object( - { - id: Type.String(), - kind: Type.Literal("heartbeat"), - intervalSeconds: Type.Number({ minimum: 5 }), - prompt: Type.String(), - sessionMode: Type.Optional(SessionModeSchema), - delivery: Type.Optional(DeliveryTargetSchema), - enabled: Type.Optional(Type.Boolean()), - }, - { additionalProperties: true }, -); - -const ProactiveTriggerSchema = Type.Union([ - CronProactiveTriggerSchema, - HeartbeatProactiveTriggerSchema, -]); - -const AgentConfigSchema = Type.Object( - { - connector: Type.Optional(Type.String()), - duckdb_path: Type.Optional(Type.String()), - ingressBindings: Type.Optional(Type.Array(IngressBindingSchema)), - deliveryDefaults: Type.Optional(DeliveryTargetSchema), - proactiveTriggers: Type.Optional(Type.Array(ProactiveTriggerSchema)), - policy: Type.Optional(PolicySchema), - }, - { additionalProperties: true }, -); - -export type AgentSessionMode = Static; -export type AgentIngressEntrypoint = Static; -export type AgentIngressBindingMatch = Static; -export type AgentIngressBinding = Static; -export type AgentDeliveryTarget = Static; -export type AgentProactiveTrigger = Static; -export type AgentQuietHoursPolicy = Static; -export type AgentPolicy = Static; -export type AgentConfig = Static; -export type ParseAgentConfigOptions = { - warn?: (line: string) => void; - context?: string; -}; - -const emittedWarnings = new Set(); - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} - -function warnOnce( - options: ParseAgentConfigOptions, - message: string, -): void { - const warn = options.warn; - if (!warn) { - return; - } - - const contextPrefix = options.context ? `${options.context}: ` : ""; - const line = `[gravity] agent config parse warning ${contextPrefix}${message}`; - if (emittedWarnings.has(line)) { - return; - } - - emittedWarnings.add(line); - warn(line); -} - -function normalizeString(value: string | undefined): string | null { - if (value === undefined) { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function normalizeIngressMatch( - rawMatch: AgentIngressBindingMatch | undefined, -): AgentIngressBindingMatch | undefined | null { - if (!rawMatch) { - return undefined; - } - - const match: AgentIngressBindingMatch = {}; - const command = normalizeString(rawMatch.command); - const channelId = normalizeString(rawMatch.channelId); - const userId = normalizeString(rawMatch.userId); - - if (rawMatch.command !== undefined && !command) { - return null; - } - if (rawMatch.channelId !== undefined && !channelId) { - return null; - } - if (rawMatch.userId !== undefined && !userId) { - return null; - } - - if (command) { - match.command = command; - } - if (channelId) { - match.channelId = channelId; - } - if (userId) { - match.userId = userId; - } - if (typeof rawMatch.isDirectMessage === "boolean") { - match.isDirectMessage = rawMatch.isDirectMessage; - } - if (typeof rawMatch.threadOwnedByAgent === "boolean") { - match.threadOwnedByAgent = rawMatch.threadOwnedByAgent; - } - - return Object.keys(match).length > 0 ? match : undefined; -} - -function normalizeDeliveryTarget( - rawDelivery: AgentDeliveryTarget, -): AgentDeliveryTarget | null { - if (rawDelivery.mode === "channel_thread") { - const channelId = normalizeString(rawDelivery.channelId); - if (!channelId) { - return null; - } - - return { - surface: "slack", - mode: "channel_thread", - channelId, - }; - } - - const userId = normalizeString(rawDelivery.userId); - if (!userId) { - return null; - } - - return { - surface: "slack", - mode: "dm", - userId, - }; -} - -function normalizeIngressBinding( - rawBinding: AgentIngressBinding, -): AgentIngressBinding | null { - const id = normalizeString(rawBinding.id); - const match = normalizeIngressMatch(rawBinding.match); - if (rawBinding.id !== undefined && !id) { - return null; - } - if (match === null) { - return null; - } - - return { - id: id ?? undefined, - kind: "message", - surface: "slack", - entrypoint: rawBinding.entrypoint, - sessionMode: rawBinding.sessionMode, - enabled: rawBinding.enabled ?? true, - match, - }; -} - -function normalizeProactiveTrigger( - rawTrigger: AgentProactiveTrigger, -): AgentProactiveTrigger | null { - const id = normalizeString(rawTrigger.id); - const prompt = normalizeString(rawTrigger.prompt); - - if (!id || !prompt) { - return null; - } - - const delivery = rawTrigger.delivery - ? (normalizeDeliveryTarget(rawTrigger.delivery) ?? undefined) - : undefined; - - if (rawTrigger.kind === "cron") { - const schedule = normalizeString(rawTrigger.schedule); - if (!schedule) { - return null; - } - - return { - id, - kind: "cron", - schedule, - prompt, - sessionMode: rawTrigger.sessionMode, - delivery, - enabled: rawTrigger.enabled ?? true, - }; - } - - const intervalSeconds = Math.floor(rawTrigger.intervalSeconds); - if (!Number.isFinite(intervalSeconds) || intervalSeconds < 5) { - return null; - } - - return { - id, - kind: "heartbeat", - intervalSeconds, - prompt, - sessionMode: rawTrigger.sessionMode, - delivery, - enabled: rawTrigger.enabled ?? true, - }; -} - -function normalizeQuietHours( - rawQuietHours: AgentQuietHoursPolicy, -): AgentQuietHoursPolicy | null { - const timezone = normalizeString(rawQuietHours.timezone); - if (!timezone) { - return null; - } - - const daysOfWeek = rawQuietHours.daysOfWeek - ? Array.from(new Set(rawQuietHours.daysOfWeek)) - : undefined; - if (daysOfWeek && daysOfWeek.length === 0) { - return null; - } - - return { - enabled: rawQuietHours.enabled ?? true, - timezone, - startHour: rawQuietHours.startHour, - endHour: rawQuietHours.endHour, - daysOfWeek, - }; -} - -function normalizePolicy(rawPolicy: AgentPolicy): AgentPolicy | null { - if (!rawPolicy.quietHours) { - return {}; - } - - const quietHours = normalizeQuietHours(rawPolicy.quietHours); - if (!quietHours) { - return null; - } - - return { - quietHours, - }; -} - -export function parseAgentConfig( - value: unknown, - options: ParseAgentConfigOptions = {}, -): AgentConfig { - if (!isRecord(value)) { - if (value !== null && value !== undefined) { - warnOnce(options, "root config is not an object; using empty config"); - } - return {}; - } - - if (!Value.Check(AgentConfigSchema, value)) { - const firstError = Value.Errors(AgentConfigSchema, value).First(); - const path = firstError?.path ?? "/"; - const message = firstError?.message ?? "schema mismatch"; - warnOnce( - options, - `invalid config at ${path} (${message}); disabling config`, - ); - return {}; - } - - const raw = value; - const normalized: AgentConfig = {}; - - if (raw.connector !== undefined) { - const connector = normalizeString(raw.connector); - if (!connector) { - warnOnce( - options, - "invalid `connector` after normalization; disabling config", - ); - return {}; - } - - normalized.connector = connector; - } - - if (raw.duckdb_path !== undefined) { - const duckdbPath = normalizeString(raw.duckdb_path); - if (!duckdbPath) { - warnOnce( - options, - "invalid `duckdb_path` after normalization; disabling config", - ); - return {}; - } - - normalized.duckdb_path = duckdbPath; - } - - if (raw.ingressBindings !== undefined) { - const ingressBindings: AgentIngressBinding[] = []; - for (const [index, candidate] of raw.ingressBindings.entries()) { - const normalizedBinding = normalizeIngressBinding(candidate); - if (!normalizedBinding) { - warnOnce( - options, - `invalid ingress binding at ingressBindings[${index}] after normalization; disabling config`, - ); - return {}; - } - - ingressBindings.push(normalizedBinding); - } - - normalized.ingressBindings = ingressBindings; - } - - if (raw.deliveryDefaults !== undefined) { - const deliveryDefaults = normalizeDeliveryTarget(raw.deliveryDefaults); - if (!deliveryDefaults) { - warnOnce( - options, - "invalid `deliveryDefaults` after normalization; disabling config", - ); - return {}; - } - - normalized.deliveryDefaults = deliveryDefaults; - } - - if (raw.proactiveTriggers !== undefined) { - const proactiveTriggers: AgentProactiveTrigger[] = []; - for (const [index, candidate] of raw.proactiveTriggers.entries()) { - const normalizedTrigger = normalizeProactiveTrigger(candidate); - if (!normalizedTrigger) { - warnOnce( - options, - `invalid proactive trigger at proactiveTriggers[${index}] after normalization; disabling config`, - ); - return {}; - } - - proactiveTriggers.push(normalizedTrigger); - } - - normalized.proactiveTriggers = proactiveTriggers; - } - - if (raw.policy !== undefined) { - const policy = normalizePolicy(raw.policy); - if (!policy) { - warnOnce(options, "invalid `policy` after normalization; disabling config"); - return {}; - } - - normalized.policy = policy; - } - - return normalized; -} diff --git a/src/runtime/config.ts b/src/runtime/config.ts index 24f1a0e..bc583f5 100644 --- a/src/runtime/config.ts +++ b/src/runtime/config.ts @@ -1,3 +1,5 @@ +import { runtimeConfig } from "../../agents/index.js"; + export type AppConfig = { env: string; databaseUrl: string; @@ -7,6 +9,9 @@ export type AppConfig = { anthropicApiKey: string | null; }; +const DEFAULT_DATABASE_URL = + "postgres://gravity:gravity@localhost:5432/gravity?sslmode=disable"; + function normalizeOptionalEnv(value: string | undefined): string | null { if (!value) { return null; @@ -17,6 +22,10 @@ function normalizeOptionalEnv(value: string | undefined): string | null { } export function loadConfig(env: NodeJS.ProcessEnv): AppConfig { + const databaseUrlEnvVar = runtimeConfig.infra.database.urlEnvVar; + const slackAppTokenEnvVar = runtimeConfig.infra.slack.appTokenEnvVar; + const slackBotTokenEnvVar = runtimeConfig.infra.slack.botTokenEnvVar; + const modelApiKeyEnvVar = runtimeConfig.infra.modelProvider.apiKeyEnvVar; const livenessIntervalRaw = env.GRAVITY_LIVENESS_INTERVAL_SECONDS ?? "30"; const livenessIntervalSeconds = Number(livenessIntervalRaw); @@ -31,12 +40,10 @@ export function loadConfig(env: NodeJS.ProcessEnv): AppConfig { return { env: env.GRAVITY_ENV ?? "dev", - databaseUrl: - env.DATABASE_URL ?? - "postgres://gravity:gravity@localhost:5432/gravity?sslmode=disable", + databaseUrl: env[databaseUrlEnvVar] ?? DEFAULT_DATABASE_URL, livenessIntervalSeconds, - slackAppToken: normalizeOptionalEnv(env.SLACK_APP_TOKEN), - slackBotToken: normalizeOptionalEnv(env.SLACK_BOT_TOKEN), - anthropicApiKey: normalizeOptionalEnv(env.ANTHROPIC_API_KEY), + slackAppToken: normalizeOptionalEnv(env[slackAppTokenEnvVar]), + slackBotToken: normalizeOptionalEnv(env[slackBotTokenEnvVar]), + anthropicApiKey: normalizeOptionalEnv(env[modelApiKeyEnvVar]), }; } diff --git a/src/runtime/context-assembler.ts b/src/runtime/context-assembler.ts new file mode 100644 index 0000000..4c4be83 --- /dev/null +++ b/src/runtime/context-assembler.ts @@ -0,0 +1,234 @@ +import path from "node:path"; +import type { CompiledAgentCapabilities } from "../../agents/capability-compiler.js"; +import type { CapabilitySkillId } from "../../agents/contracts.js"; +import { + readMarkdownFiles, + readOptionalFile, + readRequiredMarkdownFile, + resolvePathFromCwd, +} from "../resources/fs-utils.js"; +import { loadResourceContributions } from "../resources/registry.js"; +import type { ResourceDocument } from "../resources/types.js"; + +type LoadedDocument = ResourceDocument; + +export type ContextAssemblerAgent = Readonly<{ + id: string; + name: string; + description: string | null; + capabilityProfile: CompiledAgentCapabilities; + skillsPath: string | null; + memoryPath: string | null; +}>; + +export type AssembleTurnContextInput = Readonly<{ + cwd: string; + sharedRoot: string; + prompt: string; + agent: ContextAssemblerAgent; +}>; + +export type AssembledTurnContext = Readonly<{ + normalizedPrompt: string; + systemPrompt: string; +}>; + +function normalizeUserPrompt( + prompt: string, + capabilityProfile: CompiledAgentCapabilities, +): string { + const normalized = prompt.trim(); + if (normalized.length > 0) { + return normalized; + } + + const hasDuckdb = capabilityProfile.requiredResources.some( + (resource) => resource.kind === "duckdb", + ); + if (hasDuckdb) { + return "No question was provided. Ask for clarification and suggest example DuckDB business questions."; + } + + return "No question was provided. Ask for clarification and suggest next steps based on available capabilities/resources."; +} + +async function loadDeclaredSharedSkillDocs(input: { + cwd: string; + sharedRoot: string; + skillIds: readonly CapabilitySkillId[]; +}): Promise { + const uniqueSharedSkills = Array.from(new Set(input.skillIds)).sort(); + + const sharedSkillsDir = path.join( + resolvePathFromCwd(input.cwd, input.sharedRoot), + "skills", + ); + + return Promise.all( + uniqueSharedSkills.map((skillId) => { + const sharedSkillFilePath = path.join(sharedSkillsDir, `${skillId}.md`); + return readRequiredMarkdownFile(sharedSkillFilePath); + }), + ); +} + +function formatLoadedDocuments( + heading: string, + documents: LoadedDocument[], +): string { + if (documents.length === 0) { + return `${heading}\n(none loaded)`; + } + + const sections = documents.map((document) => { + return [ + `File: ${document.filePath}`, + "```markdown", + document.content, + "```", + ].join("\n"); + }); + + return [heading, ...sections].join("\n\n"); +} + +function buildSystemPrompt(input: { + agent: ContextAssemblerAgent; + sharedSkillDocs: LoadedDocument[]; + resourceDocs: LoadedDocument[]; + resourceContextDocs: LoadedDocument[]; + agentSkills: LoadedDocument[]; + memoryContent: string | null; + capabilityGuidance: readonly string[]; + resourceGuidance: readonly string[]; + hasDuckdbResource: boolean; +}): string { + const description = input.agent.description ?? "No description provided."; + const memoryBlock = + input.memoryContent ?? + "No prior memory is recorded yet for this agent."; + + const operatingExpectations = [ + "- Answer directly in plain business language.", + "- Show supporting metrics and call out assumptions or caveats.", + "- Keep responses concise and Slack-readable.", + "- Do not invent table or column names; inspect available docs when unsure.", + ...(input.hasDuckdbResource + ? ["- Use DuckDB for factual claims when a query is needed."] + : []), + ]; + + const resourceContextHeading = input.hasDuckdbResource + ? "dbt schema/docs context loaded this turn:" + : "Resource-specific schema/docs context loaded this turn:"; + + return [ + `You are ${input.agent.name} (${input.agent.id}).`, + description, + "", + "Operating expectations:", + ...operatingExpectations, + "", + "Active capabilities:", + ...input.capabilityGuidance, + "", + "Resource guidance:", + ...input.resourceGuidance, + "", + "Agent memory:", + "```markdown", + memoryBlock, + "```", + "", + formatLoadedDocuments("Shared skills loaded this turn:", input.sharedSkillDocs), + "", + formatLoadedDocuments( + "Shared resource docs loaded this turn:", + input.resourceDocs, + ), + "", + formatLoadedDocuments( + "Agent-local skills loaded this turn:", + input.agentSkills, + ), + "", + formatLoadedDocuments( + resourceContextHeading, + input.resourceContextDocs, + ), + ].join("\n"); +} + +export async function assembleTurnContext( + input: AssembleTurnContextInput, +): Promise { + const normalizedPrompt = normalizeUserPrompt( + input.prompt, + input.agent.capabilityProfile, + ); + + const agentSkillsDir = input.agent.skillsPath + ? resolvePathFromCwd(input.cwd, input.agent.skillsPath) + : null; + const agentMemoryFilePath = input.agent.memoryPath + ? path.join(resolvePathFromCwd(input.cwd, input.agent.memoryPath), "MEMORY.md") + : null; + + const [sharedSkillDocs, agentSkills, resourceContributions] = await Promise.all([ + loadDeclaredSharedSkillDocs({ + cwd: input.cwd, + sharedRoot: input.sharedRoot, + skillIds: input.agent.capabilityProfile.requiredSkillIds, + }), + agentSkillsDir ? readMarkdownFiles(agentSkillsDir) : Promise.resolve([]), + loadResourceContributions({ + resources: input.agent.capabilityProfile.requiredResources, + cwd: input.cwd, + sharedRoot: input.sharedRoot, + }), + ]); + + const rawResourceGuidance = resourceContributions.flatMap( + (contribution) => contribution.guidance, + ); + const resourceGuidance = + rawResourceGuidance.length > 0 + ? rawResourceGuidance + : [ + "- No external resources configured; rely on loaded skills, memory, and conversation context.", + ]; + const capabilityGuidance = + input.agent.capabilityProfile.capabilityGuidance.length > 0 + ? input.agent.capabilityProfile.capabilityGuidance + : ["- No capabilities configured."]; + const resourceDocs = resourceContributions.flatMap( + (contribution) => contribution.sharedDocs, + ); + const resourceContextDocs = resourceContributions.flatMap( + (contribution) => contribution.contextDocs, + ); + const hasDuckdbResource = resourceContributions.some( + (contribution) => contribution.resourceKind === "duckdb", + ); + + const memoryContent = agentMemoryFilePath + ? await readOptionalFile(agentMemoryFilePath) + : null; + + const systemPrompt = buildSystemPrompt({ + agent: input.agent, + sharedSkillDocs, + resourceDocs, + resourceContextDocs, + agentSkills, + memoryContent, + capabilityGuidance, + resourceGuidance, + hasDuckdbResource, + }); + + return { + normalizedPrompt, + systemPrompt, + }; +} diff --git a/src/runtime/executor-manager.ts b/src/runtime/executor-manager.ts new file mode 100644 index 0000000..7d07893 --- /dev/null +++ b/src/runtime/executor-manager.ts @@ -0,0 +1,80 @@ +import { createBashTool, createReadTool } from "@mariozechner/pi-coding-agent"; +import type { ToolPrimitive } from "../../agents/tool-primitives.js"; + +export type ExecutorRuntime = "host" | "sandbox"; + +type PiTool = ReturnType | ReturnType; + +export type Executor = Readonly<{ + id: string; + runtime: ExecutorRuntime; + createTools: ( + cwd: string, + allowedToolPrimitives: readonly ToolPrimitive[], + ) => PiTool[]; +}>; + +export type ExecutorManager = Readonly<{ + resolve: (runtime: ExecutorRuntime) => Executor; +}>; + +type ExecutorManagerConfig = { + enableSandbox?: boolean; + log?: (line: string) => void; +}; + +function createHostExecutor(): Executor { + return { + id: "host", + runtime: "host", + createTools: (cwd, allowedToolPrimitives) => { + const allowedSet = new Set(allowedToolPrimitives); + const tools: PiTool[] = []; + if (allowedSet.has("read")) { + tools.push(createReadTool(cwd)); + } + if (allowedSet.has("bash")) { + tools.push(createBashTool(cwd)); + } + return tools; + }, + }; +} + +function createSandboxScaffoldExecutor(): Executor { + return { + id: "sandbox-scaffold-disabled", + runtime: "sandbox", + createTools: () => { + throw new Error( + "Sandbox executor scaffold is disabled for CP5.1. Use runtime=host or enable sandbox in a later checkpoint.", + ); + }, + }; +} + +export function createExecutorManager( + config: ExecutorManagerConfig = {}, +): ExecutorManager { + const hostExecutor = createHostExecutor(); + const sandboxEnabled = config.enableSandbox ?? false; + const sandboxExecutor = createSandboxScaffoldExecutor(); + const log = config.log ?? console.log; + + return { + resolve(runtime) { + if (runtime === "sandbox") { + if (!sandboxEnabled) { + throw new Error( + "Sandbox runtime requested but sandbox executor scaffold is disabled.", + ); + } + + log("[gravity] sandbox runtime selected (scaffold executor)"); + return sandboxExecutor; + } + + return hostExecutor; + }, + }; +} diff --git a/src/runtime/ingress-binding-resolver.ts b/src/runtime/ingress-binding-resolver.ts deleted file mode 100644 index cf9c497..0000000 --- a/src/runtime/ingress-binding-resolver.ts +++ /dev/null @@ -1,194 +0,0 @@ -import type { - AgentConfig, - AgentIngressBindingMatch, -} from "./agent-config.js"; -import type { InboundSlackMessage } from "./slack-transport.js"; -import type { SessionMode } from "./session-catalog.js"; - -export type MessageEntrypoint = "app_mention" | "thread_reply" | "direct_message"; - -export type ActiveAgentIngressRow = { - id: string; - channel_id: string | null; - config: AgentConfig; -}; - -export type ResolvedMessageIngress = { - agentId: string; - entrypoint: MessageEntrypoint; - sessionMode: SessionMode; - route: "binding"; -}; - -export type ResolveMessageIngressOptions = { - threadOwnerAgentId?: string | null; -}; - -type IngressBinding = { - kind: "message"; - surface: "slack"; - entrypoint: MessageEntrypoint; - enabled: boolean; - sessionMode: SessionMode; - match: AgentIngressBindingMatch; -}; - -function defaultSessionModeForEntrypoint(entrypoint: MessageEntrypoint): SessionMode { - return entrypoint === "direct_message" ? "main" : "thread"; -} - -function parseIngressBindings(config: AgentConfig): IngressBinding[] { - const bindings: IngressBinding[] = []; - - for (const rawBinding of config.ingressBindings ?? []) { - if (!rawBinding || typeof rawBinding !== "object") { - continue; - } - - if ( - rawBinding.kind !== "message" || - rawBinding.surface !== "slack" || - rawBinding.entrypoint === "slash_command" - ) { - continue; - } - - const enabled = rawBinding.enabled ?? true; - if (!enabled) { - continue; - } - - const entrypoint = rawBinding.entrypoint; - bindings.push({ - kind: "message", - surface: "slack", - entrypoint, - enabled, - sessionMode: - rawBinding.sessionMode ?? - defaultSessionModeForEntrypoint(entrypoint), - match: rawBinding.match ?? {}, - }); - } - - return bindings; -} - -function deriveMessageEntrypoint( - message: InboundSlackMessage, -): MessageEntrypoint | null { - if (message.surface === "app_mention") { - return "app_mention"; - } - - if (message.threadTs !== message.messageTs) { - return "thread_reply"; - } - - if (message.isDirectMessage) { - return "direct_message"; - } - - return null; -} - -function bindingMatchesMessage( - binding: IngressBinding, - agent: ActiveAgentIngressRow, - message: InboundSlackMessage, - entrypoint: MessageEntrypoint, - options: ResolveMessageIngressOptions, -): boolean { - if (binding.entrypoint !== entrypoint) { - return false; - } - - const matchChannelId = binding.match.channelId; - if (matchChannelId && matchChannelId !== message.channelId) { - return false; - } - - const matchUserId = binding.match.userId; - if (matchUserId && matchUserId !== message.userId) { - return false; - } - - const matchIsDirectMessage = binding.match.isDirectMessage; - if ( - matchIsDirectMessage !== undefined && - matchIsDirectMessage !== message.isDirectMessage - ) { - return false; - } - - const matchThreadOwnedByAgent = binding.match.threadOwnedByAgent; - if (matchThreadOwnedByAgent === true && entrypoint !== "thread_reply") { - return false; - } - if (matchThreadOwnedByAgent === true) { - return options.threadOwnerAgentId === agent.id; - } - if (matchThreadOwnedByAgent === false && options.threadOwnerAgentId === agent.id) { - return false; - } - - return true; -} - -type ResolvedBindingCandidate = ResolvedMessageIngress & { - channelAffinityScore: number; -}; - -function sortResolvedMatches(a: ResolvedBindingCandidate, b: ResolvedBindingCandidate): number { - if (a.channelAffinityScore !== b.channelAffinityScore) { - return b.channelAffinityScore - a.channelAffinityScore; - } - - return a.agentId.localeCompare(b.agentId); -} - -export function resolveMessageIngress( - message: InboundSlackMessage, - agents: ReadonlyArray, - options: ResolveMessageIngressOptions = {}, -): ResolvedMessageIngress | null { - const entrypoint = deriveMessageEntrypoint(message); - if (!entrypoint) { - return null; - } - - const resolvedFromBindings: ResolvedBindingCandidate[] = []; - for (const agent of agents) { - const bindings = parseIngressBindings(agent.config); - for (const binding of bindings) { - if (!bindingMatchesMessage(binding, agent, message, entrypoint, options)) { - continue; - } - - resolvedFromBindings.push({ - agentId: agent.id, - entrypoint, - sessionMode: binding.sessionMode, - route: "binding", - channelAffinityScore: agent.channel_id === message.channelId ? 1 : 0, - }); - } - } - - if (resolvedFromBindings.length > 0) { - resolvedFromBindings.sort(sortResolvedMatches); - const winner = resolvedFromBindings[0]; - if (!winner) { - return null; - } - - return { - agentId: winner.agentId, - entrypoint: winner.entrypoint, - sessionMode: winner.sessionMode, - route: winner.route, - }; - } - - return null; -} diff --git a/src/runtime/pi-agent-runner.ts b/src/runtime/pi-agent-runner.ts index 2eeb452..d9ba0d0 100644 --- a/src/runtime/pi-agent-runner.ts +++ b/src/runtime/pi-agent-runner.ts @@ -1,12 +1,16 @@ -import { mkdir, readdir, readFile, stat } from "node:fs/promises"; +import { mkdir } from "node:fs/promises"; import path from "node:path"; +import { agentRegistry } from "../../agents/index.js"; +import type { CompiledAgentCapabilities } from "../../agents/capability-compiler.js"; +import { + assembleTurnContext, + type ContextAssemblerAgent, +} from "./context-assembler.js"; import { type Api, getModels, type Model } from "@mariozechner/pi-ai"; import { AuthStorage, createAgentSession, - createBashTool, createExtensionRuntime, - createReadTool, ModelRegistry, SessionManager, SettingsManager, @@ -16,12 +20,13 @@ import { import { type Static, Type } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; import type { Kysely } from "kysely"; -import { parseAgentConfig, type AgentConfig } from "./agent-config.js"; import { gravitySchema, type GravityDatabase } from "./db.js"; +import type { + ExecutorManager, + ExecutorRuntime, +} from "./executor-manager.js"; -const DEFAULT_ANTHROPIC_MODEL_ID = "claude-opus-4-6"; -const MAX_DBT_CONTEXT_FILES = 10; -const MAX_DBT_FILE_CHARS = 6000; +const DEFAULT_ANTHROPIC_MODEL_ID = "claude-sonnet-4-5"; type AgentRuntimeRecord = { id: string; @@ -30,12 +35,7 @@ type AgentRuntimeRecord = { model: string; skills_path: string | null; memory_path: string | null; - config: AgentConfig; -}; - -type LoadedDocument = { - filePath: string; - content: string; + compiledCapabilities: CompiledAgentCapabilities; }; const AgentAssistantMessageSchema = Type.Object( @@ -52,9 +52,11 @@ type AgentAssistantMessage = Static; export type RunPiAgentTurnInput = { db: Kysely; agentId: string; + agentRuntime: ExecutorRuntime; sessionKey: string; prompt: string; anthropicApiKey: string | null; + executorManager: ExecutorManager; }; export type RunPiAgentTurnResult = { @@ -91,15 +93,6 @@ function resolvePathFromRepoRoot(inputPath: string): string { return path.resolve(process.cwd(), inputPath); } -function normalizeUserPrompt(prompt: string): string { - const normalized = prompt.trim(); - if (normalized.length > 0) { - return normalized; - } - - return "No question was provided. Ask for clarification and suggest example DuckDB business questions."; -} - function resolveAnthropicModelId(preferredModelId: string): Model { const models = getModels("anthropic"); if (models.length === 0) { @@ -145,202 +138,6 @@ function extractAssistantText(content: unknown): string { return textParts.join("\n\n").trim(); } -async function readMarkdownFiles(dirPath: string): Promise { - try { - const directoryEntries = await readdir(dirPath, { withFileTypes: true }); - const markdownFiles = directoryEntries - .filter((entry) => entry.isFile() && entry.name.endsWith(".md")) - .map((entry) => entry.name) - .sort(); - - const loadedFiles = await Promise.all( - markdownFiles.map(async (fileName) => { - const filePath = path.join(dirPath, fileName); - const content = await readFile(filePath, "utf8"); - return { - filePath, - content: content.trim(), - } satisfies LoadedDocument; - }), - ); - - return loadedFiles.filter((document) => document.content.length > 0); - } catch { - return []; - } -} - -async function readOptionalFile(filePath: string): Promise { - try { - const content = await readFile(filePath, "utf8"); - const trimmed = content.trim(); - return trimmed.length > 0 ? trimmed : null; - } catch { - return null; - } -} - -async function walkDbtMetadataFiles(modelsDir: string): Promise { - const discoveredFiles: string[] = []; - const stack = [modelsDir]; - - while (stack.length > 0) { - const nextDir = stack.pop(); - if (!nextDir) { - continue; - } - - let entries: Array<{ isDirectory: () => boolean; isFile: () => boolean; name: string }>; - try { - const dirEntries = await readdir(nextDir, { - withFileTypes: true, - encoding: "utf8", - }); - entries = dirEntries; - } catch { - continue; - } - - const sortedEntries = [...entries].sort((a, b) => a.name.localeCompare(b.name)); - for (const entry of sortedEntries) { - const entryPath = path.join(nextDir, entry.name); - if (entry.isDirectory()) { - stack.push(entryPath); - continue; - } - - if (!entry.isFile()) { - continue; - } - - if ( - entry.name.endsWith(".yml") || - entry.name.endsWith(".yaml") || - entry.name.endsWith(".md") - ) { - discoveredFiles.push(entryPath); - } - } - } - - discoveredFiles.sort(); - return discoveredFiles.slice(0, MAX_DBT_CONTEXT_FILES); -} - -async function loadDbtContextFromDuckdbPath( - duckdbPath: string | null, -): Promise { - if (!duckdbPath) { - return []; - } - - const projectRoot = path.dirname(duckdbPath); - const modelsDir = path.join(projectRoot, "models"); - - try { - const modelsDirStats = await stat(modelsDir); - if (!modelsDirStats.isDirectory()) { - return []; - } - } catch { - return []; - } - - const metadataFilePaths = await walkDbtMetadataFiles(modelsDir); - const documents: LoadedDocument[] = []; - - for (const metadataFilePath of metadataFilePaths) { - const content = await readOptionalFile(metadataFilePath); - if (!content) { - continue; - } - - documents.push({ - filePath: metadataFilePath, - content: content.slice(0, MAX_DBT_FILE_CHARS).trim(), - }); - } - - return documents; -} - -function formatLoadedDocuments( - heading: string, - documents: LoadedDocument[], -): string { - if (documents.length === 0) { - return `${heading}\n(none loaded)`; - } - - const sections = documents.map((document) => { - return [ - `File: ${document.filePath}`, - "```markdown", - document.content, - "```", - ].join("\n"); - }); - - return [heading, ...sections].join("\n\n"); -} - -function buildSystemPrompt(input: { - agent: AgentRuntimeRecord; - sharedSkills: LoadedDocument[]; - connectorDocs: LoadedDocument[]; - agentSkills: LoadedDocument[]; - memoryContent: string | null; - dbtContextDocs: LoadedDocument[]; - duckdbPath: string | null; -}): string { - const description = input.agent.description ?? "No description provided."; - const memoryBlock = - input.memoryContent ?? - "No prior memory is recorded yet for this agent."; - const duckdbPathLine = - input.duckdbPath ?? - "DuckDB path not configured in agent config. Ask for configuration before running queries."; - - return [ - `You are ${input.agent.name} (${input.agent.id}).`, - description, - "", - "Operating expectations:", - "- Answer directly in plain business language.", - "- Use DuckDB for factual claims when a query is needed.", - "- Show supporting metrics and call out assumptions or caveats.", - "- Keep responses concise and Slack-readable.", - "- Do not invent table or column names; inspect schema/docs when unsure.", - "", - "DuckDB execution contract:", - `- Preferred command pattern: duckdb ${duckdbPathLine} -cmd \"\"`, - "- Use `bash` for SQL execution and `read` for inspecting files/docs.", - "- If output is truncated, follow the truncation hint or rerun a narrower query.", - "", - "Agent memory:", - "```markdown", - memoryBlock, - "```", - "", - formatLoadedDocuments("Shared skills loaded this turn:", input.sharedSkills), - "", - formatLoadedDocuments( - "Shared connector docs loaded this turn:", - input.connectorDocs, - ), - "", - formatLoadedDocuments( - "Agent-specific skills loaded this turn:", - input.agentSkills, - ), - "", - formatLoadedDocuments( - "dbt schema/docs context loaded this turn:", - input.dbtContextDocs, - ), - ].join("\n"); -} - function createStaticResourceLoader(systemPrompt: string): ResourceLoader { const extensionRuntime = createExtensionRuntime(); @@ -364,6 +161,25 @@ function createStaticResourceLoader(systemPrompt: string): ResourceLoader { }; } +function loadCodeDefinedAgentDetails(agentId: string): { + name: string; + description: string | null; + model: string; + compiledCapabilities: CompiledAgentCapabilities; +} { + const registered = agentRegistry.agentsById.get(agentId); + if (!registered) { + throw new Error(`Agent declaration not found for id: ${agentId}`); + } + + return { + name: registered.declaration.name, + description: registered.declaration.description ?? null, + model: registered.model, + compiledCapabilities: registered.compiledCapabilities, + }; +} + async function loadAgentRuntimeRecord( db: Kysely, agentId: string, @@ -372,12 +188,8 @@ async function loadAgentRuntimeRecord( .selectFrom("agents") .select([ "id", - "name", - "description", - "model", "skills_path", "memory_path", - "config", ]) .where("id", "=", agentId) .where("status", "=", "active") @@ -387,19 +199,36 @@ async function loadAgentRuntimeRecord( throw new Error(`Active agent not found for id: ${agentId}`); } + const declaration = loadCodeDefinedAgentDetails(agentId); + return { - ...row, - config: parseAgentConfig(row.config, { - warn: console.warn, - context: `agentId=${row.id}`, - }), + id: row.id, + name: declaration.name, + description: declaration.description, + model: declaration.model, + skills_path: row.skills_path, + memory_path: row.memory_path, + compiledCapabilities: declaration.compiledCapabilities, + }; +} + +function toAssemblerAgent(record: AgentRuntimeRecord): ContextAssemblerAgent { + return { + id: record.id, + name: record.name, + description: record.description, + capabilityProfile: record.compiledCapabilities, + skillsPath: asStringOrNull(record.skills_path), + memoryPath: asStringOrNull(record.memory_path), }; } function createSessionContextPath(agentId: string, sessionKey: string): string { + const workspaceRoot = resolvePathFromRepoRoot( + agentRegistry.config.paths.workspaceRoot, + ); return path.join( - process.cwd(), - "workspace", + workspaceRoot, agentId, "sessions", sessionKey, @@ -424,48 +253,14 @@ export async function runPiAgentTurn( } const agent = await loadAgentRuntimeRecord(input.db, input.agentId); - const normalizedPrompt = normalizeUserPrompt(input.prompt); - const skillPath = asStringOrNull(agent.skills_path); - const memoryPath = asStringOrNull(agent.memory_path); - const connectorName = agent.config.connector ?? null; - const duckdbPath = agent.config.duckdb_path ?? null; - - const sharedSkillsDir = resolvePathFromRepoRoot("store/shared/skills"); - const sharedConnectorsDir = resolvePathFromRepoRoot("store/shared/connectors"); - const agentSkillsDir = skillPath ? resolvePathFromRepoRoot(skillPath) : null; - const agentMemoryFilePath = memoryPath - ? path.join(resolvePathFromRepoRoot(memoryPath), "MEMORY.md") - : null; - - const [sharedSkills, agentSkills, dbtContextDocs] = await Promise.all([ - readMarkdownFiles(sharedSkillsDir), - agentSkillsDir ? readMarkdownFiles(agentSkillsDir) : Promise.resolve([]), - loadDbtContextFromDuckdbPath(duckdbPath), - ]); - - const connectorDocs = connectorName - ? await readMarkdownFiles(sharedConnectorsDir).then((documents) => - documents.filter((document) => - path.basename(document.filePath).startsWith(connectorName), - ), - ) - : []; - - const memoryContent = agentMemoryFilePath - ? await readOptionalFile(agentMemoryFilePath) - : null; - - const systemPrompt = buildSystemPrompt({ - agent, - sharedSkills, - connectorDocs, - agentSkills, - memoryContent, - dbtContextDocs, - duckdbPath, + const assembledContext = await assembleTurnContext({ + cwd: process.cwd(), + sharedRoot: agentRegistry.config.paths.sharedRoot, + prompt: input.prompt, + agent: toAssemblerAgent(agent), }); - const resourceLoader = createStaticResourceLoader(systemPrompt); + const resourceLoader = createStaticResourceLoader(assembledContext.systemPrompt); const authStorage = new AuthStorage( path.join(process.cwd(), ".pi", "gravity", "auth.json"), ); @@ -473,6 +268,13 @@ export async function runPiAgentTurn( const modelRegistry = new ModelRegistry(authStorage); const model = resolveAnthropicModelId(agent.model); + const executor = input.executorManager.resolve(input.agentRuntime); + const allowedToolPrimitives = agent.compiledCapabilities.toolPrimitives; + if (allowedToolPrimitives.length === 0) { + throw new Error( + `Agent ${agent.id} has no granted tool primitives from capabilities.`, + ); + } const sessionContextPath = createSessionContextPath(input.agentId, input.sessionKey); const sessionDir = path.dirname(sessionContextPath); await mkdir(sessionDir, { recursive: true }); @@ -489,7 +291,7 @@ export async function runPiAgentTurn( modelRegistry, model, thinkingLevel: "high", - tools: [createReadTool(process.cwd()), createBashTool(process.cwd())], + tools: executor.createTools(process.cwd(), allowedToolPrimitives), resourceLoader, sessionManager, settingsManager, @@ -518,7 +320,7 @@ export async function runPiAgentTurn( } }); - await session.prompt(normalizedPrompt); + await session.prompt(assembledContext.normalizedPrompt); if (sessionErrorMessage) { throw new Error(sessionErrorMessage); diff --git a/src/runtime/proactive-trigger-resolver.ts b/src/runtime/proactive-trigger-resolver.ts deleted file mode 100644 index be56eb4..0000000 --- a/src/runtime/proactive-trigger-resolver.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { - AgentConfig, - AgentDeliveryTarget, - AgentQuietHoursPolicy, - AgentProactiveTrigger, -} from "./agent-config.js"; -import type { SessionMode } from "./session-catalog.js"; -import type { ProactiveTriggerKind } from "./trigger-normalizer.js"; - -export type ActiveAgentProactiveRow = { - id: string; - channel_id: string | null; - config: AgentConfig; -}; - -export type ProactiveDeliveryTarget = - | { - surface: "slack"; - mode: "channel_thread"; - channelId: string; - } - | { - surface: "slack"; - mode: "dm"; - userId: string; - }; - -export type ProactiveQuietHours = { - timezone: string; - startHour: number; - endHour: number; - daysOfWeek?: number[]; -}; - -type BaseResolvedProactiveTrigger = { - agentId: string; - triggerId: string; - kind: ProactiveTriggerKind; - prompt: string; - sessionMode: SessionMode; - delivery: ProactiveDeliveryTarget; - quietHours?: ProactiveQuietHours; -}; - -export type ResolvedProactiveTrigger = - | (BaseResolvedProactiveTrigger & { - kind: "cron"; - schedule: string; - }) - | (BaseResolvedProactiveTrigger & { - kind: "heartbeat"; - intervalSeconds: number; - }); - -function normalizeNonEmptyString(value: string | null | undefined): string | null { - if (value === null || value === undefined) { - return null; - } - - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -function parseDelivery(raw: AgentDeliveryTarget | undefined): ProactiveDeliveryTarget | null { - if (!raw) { - return null; - } - - if (raw.mode === "channel_thread") { - const channelId = normalizeNonEmptyString(raw.channelId); - if (!channelId) { - return null; - } - - return { - surface: "slack", - mode: "channel_thread", - channelId, - }; - } - - const userId = normalizeNonEmptyString(raw.userId); - if (!userId) { - return null; - } - - return { - surface: "slack", - mode: "dm", - userId, - }; -} - -function resolveDeliveryTarget(input: { - rawTrigger: AgentProactiveTrigger; - rawDefaults: AgentDeliveryTarget | undefined; - fallbackChannelId: string | null; -}): ProactiveDeliveryTarget | null { - const explicit = parseDelivery(input.rawTrigger.delivery); - if (explicit) { - return explicit; - } - - const defaults = parseDelivery(input.rawDefaults); - if (defaults) { - return defaults; - } - - const fallbackChannelId = normalizeNonEmptyString(input.fallbackChannelId); - if (fallbackChannelId) { - return { - surface: "slack", - mode: "channel_thread", - channelId: fallbackChannelId, - }; - } - - return null; -} - -function normalizeSessionMode( - rawSessionMode: SessionMode | undefined, - delivery: ProactiveDeliveryTarget, -): SessionMode { - const requested = rawSessionMode ?? "isolated"; - if (requested === "thread" && delivery.mode === "dm") { - return "main"; - } - - return requested; -} - -function resolveQuietHours( - rawQuietHours: AgentQuietHoursPolicy | undefined, -): ProactiveQuietHours | undefined { - if (!rawQuietHours || rawQuietHours.enabled === false) { - return undefined; - } - - return { - timezone: rawQuietHours.timezone, - startHour: rawQuietHours.startHour, - endHour: rawQuietHours.endHour, - daysOfWeek: rawQuietHours.daysOfWeek - ? [...rawQuietHours.daysOfWeek] - : undefined, - }; -} - -export function resolveProactiveTriggers( - agents: ReadonlyArray, -): ResolvedProactiveTrigger[] { - const resolved: ResolvedProactiveTrigger[] = []; - - for (const agent of agents) { - const rawTriggers = agent.config.proactiveTriggers ?? []; - const quietHours = resolveQuietHours(agent.config.policy?.quietHours); - - for (const rawTrigger of rawTriggers) { - if (!rawTrigger || typeof rawTrigger !== "object") { - continue; - } - - if (rawTrigger.enabled === false) { - continue; - } - - const triggerId = normalizeNonEmptyString(rawTrigger.id); - const prompt = normalizeNonEmptyString(rawTrigger.prompt); - if (!triggerId || !prompt) { - continue; - } - - const delivery = resolveDeliveryTarget({ - rawTrigger, - rawDefaults: agent.config.deliveryDefaults, - fallbackChannelId: agent.channel_id, - }); - if (!delivery) { - continue; - } - - const sessionMode = normalizeSessionMode(rawTrigger.sessionMode, delivery); - - if (rawTrigger.kind === "cron") { - const schedule = normalizeNonEmptyString(rawTrigger.schedule); - if (!schedule) { - continue; - } - - resolved.push({ - agentId: agent.id, - triggerId, - kind: "cron", - schedule, - prompt, - sessionMode, - delivery, - ...(quietHours ? { quietHours } : {}), - }); - continue; - } - - const intervalSeconds = Math.floor(rawTrigger.intervalSeconds); - if (!Number.isFinite(intervalSeconds) || intervalSeconds < 5) { - continue; - } - - resolved.push({ - agentId: agent.id, - triggerId, - kind: "heartbeat", - intervalSeconds, - prompt, - sessionMode, - delivery, - ...(quietHours ? { quietHours } : {}), - }); - } - } - - return resolved; -} diff --git a/src/runtime/proactive-trigger-scheduler.ts b/src/runtime/proactive-trigger-scheduler.ts index 3aa57fd..f1a6819 100644 --- a/src/runtime/proactive-trigger-scheduler.ts +++ b/src/runtime/proactive-trigger-scheduler.ts @@ -1,16 +1,48 @@ import { Cron } from "croner"; import type { Kysely } from "kysely"; -import { parseAgentConfig } from "./agent-config.js"; import { type GravityDatabase, gravitySchema } from "./db.js"; -import { - resolveProactiveTriggers, - type ActiveAgentProactiveRow, - type ProactiveDeliveryTarget, - type ProactiveQuietHours, - type ResolvedProactiveTrigger, -} from "./proactive-trigger-resolver.js"; import type { SessionMode } from "./session-catalog.js"; -import type { ProactiveTriggerKind } from "./trigger-normalizer.js"; + +export type ProactiveTriggerKind = "cron" | "heartbeat"; + +export type ProactiveDeliveryTarget = + | { + surface: "slack"; + mode: "channel_thread"; + channelId: string; + } + | { + surface: "slack"; + mode: "dm"; + userId: string; + }; + +export type ProactiveQuietHours = { + timezone: string; + startHour: number; + endHour: number; + daysOfWeek?: number[]; +}; + +type BaseResolvedProactiveTrigger = { + agentId: string; + triggerId: string; + kind: ProactiveTriggerKind; + prompt: string; + sessionMode: SessionMode; + delivery: ProactiveDeliveryTarget; + quietHours?: ProactiveQuietHours; +}; + +export type ResolvedProactiveTrigger = + | (BaseResolvedProactiveTrigger & { + kind: "cron"; + schedule: string; + }) + | (BaseResolvedProactiveTrigger & { + kind: "heartbeat"; + intervalSeconds: number; + }); type ProactiveTriggerOrigin = "scheduled" | "replay" | "manual"; @@ -21,6 +53,11 @@ export type ProactiveTriggerFireEvent = { agentId: string; triggerId: string; kind: ProactiveTriggerKind; + trigger: { + triggerKind: ProactiveTriggerKind; + surface: "system"; + entrypoint: ProactiveTriggerKind; + }; prompt: string; sessionMode: SessionMode; delivery: ProactiveDeliveryTarget; @@ -46,6 +83,9 @@ export type ProactiveTriggerScheduler = { type ProactiveTriggerSchedulerConfig = { db: Kysely; onTrigger: (event: ProactiveTriggerFireEvent) => Promise | void; + loadTriggers: ( + db: Kysely, + ) => Promise>; log?: (line: string) => void; now?: () => Date; enableReplay?: boolean; @@ -74,25 +114,6 @@ const WEEKDAY_TO_INDEX: Record = { sat: 6, }; -async function loadActiveAgentProactiveRows( - db: Kysely, -): Promise { - const rows = await gravitySchema(db) - .selectFrom("agents") - .select(["id", "channel_id", "config"]) - .where("status", "=", "active") - .execute(); - - return rows.map((row) => ({ - id: row.id, - channel_id: row.channel_id, - config: parseAgentConfig(row.config, { - warn: console.warn, - context: `agentId=${row.id}`, - }), - })); -} - function normalizeErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; @@ -317,6 +338,7 @@ function triggerKey(trigger: ResolvedProactiveTrigger): string { export function createProactiveTriggerScheduler( config: ProactiveTriggerSchedulerConfig, ): ProactiveTriggerScheduler { + const loadTriggers = config.loadTriggers; const log = config.log ?? console.log; const now = config.now ?? (() => new Date()); const enableReplay = config.enableReplay ?? true; @@ -378,6 +400,11 @@ export function createProactiveTriggerScheduler( agentId: trigger.agentId, triggerId: trigger.triggerId, kind: trigger.kind, + trigger: { + triggerKind: trigger.kind, + surface: "system", + entrypoint: trigger.kind, + }, prompt: trigger.prompt, sessionMode: trigger.sessionMode, delivery: trigger.delivery, @@ -430,9 +457,8 @@ export function createProactiveTriggerScheduler( async function reloadTriggers(): Promise { stopHandles(); - const activeAgents = await loadActiveAgentProactiveRows(config.db); - const triggers = resolveProactiveTriggers(activeAgents); - currentTriggers = triggers; + const triggers = await loadTriggers(config.db); + currentTriggers = [...triggers]; const nextHandles: ScheduledHandle[] = []; for (const trigger of triggers) { diff --git a/src/runtime/session-key.ts b/src/runtime/session-key.ts new file mode 100644 index 0000000..231133c --- /dev/null +++ b/src/runtime/session-key.ts @@ -0,0 +1,92 @@ +import type { SessionMode } from "./session-catalog.js"; + +export function buildMainSessionKey(agentId: string): string { + return `${agentId}:main`; +} + +export function buildIsolatedSessionKey( + agentId: string, + sourceEventId: string, +): string { + return `${agentId}:${sourceEventId}`; +} + +export function buildThreadSessionKey(agentId: string, threadTs: string): string { + return `${agentId}:${threadTs}`; +} + +export function buildDmThreadFallbackSessionKey( + agentId: string, + channelId: string, +): string { + return `${agentId}:${channelId}`; +} + +export function buildSlashThreadSessionKey( + agentId: string, + threadTs: string, +): string { + return buildThreadSessionKey(agentId, threadTs); +} + +export function buildSlashSessionKey(input: { + agentId: string; + channelId: string; + threadTs: string; + sourceEventId: string; + sessionMode: SessionMode; +}): string { + if (input.sessionMode === "main") { + return buildMainSessionKey(input.agentId); + } + + if (input.sessionMode === "isolated") { + return buildIsolatedSessionKey(input.agentId, input.sourceEventId); + } + + if (input.threadTs.trim().length === 0) { + return buildDmThreadFallbackSessionKey(input.agentId, input.channelId); + } + + return buildSlashThreadSessionKey(input.agentId, input.threadTs); +} + +export function buildMessageSessionKey(input: { + agentId: string; + channelId: string; + threadTs: string; + sourceEventId: string; + sessionMode: SessionMode; + isDirectMessage: boolean; +}): string { + if (input.sessionMode === "main") { + return buildMainSessionKey(input.agentId); + } + + if (input.sessionMode === "isolated") { + return buildIsolatedSessionKey(input.agentId, input.sourceEventId); + } + + if (input.isDirectMessage) { + return buildDmThreadFallbackSessionKey(input.agentId, input.channelId); + } + + return buildThreadSessionKey(input.agentId, input.threadTs); +} + +export function buildProactiveSessionKey(input: { + agentId: string; + triggerId: string; + sourceEventId: string; + sessionMode: SessionMode; +}): string { + if (input.sessionMode === "main") { + return buildMainSessionKey(input.agentId); + } + + if (input.sessionMode === "thread") { + return `${input.agentId}:proactive:${input.triggerId}:thread`; + } + + return `${input.agentId}:proactive:${input.triggerId}:${input.sourceEventId}`; +} diff --git a/src/runtime/slack-transport.ts b/src/runtime/slack-transport.ts index 9606819..8c76cee 100644 --- a/src/runtime/slack-transport.ts +++ b/src/runtime/slack-transport.ts @@ -3,7 +3,6 @@ import { type Static, type TSchema, Type } from "@sinclair/typebox"; import { Value } from "@sinclair/typebox/value"; import { SocketModeClient } from "@slack/socket-mode"; import { WebClient } from "@slack/web-api"; -import { normalizeSlashCommand } from "./slash-command-router.js"; export type SlackSurface = "app_mention" | "message"; export type SlackCommandSurface = "slash_command"; @@ -92,6 +91,10 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +export function normalizeSlashCommand(command: string): string { + return command.trim().toLowerCase(); +} + function parseSocketEnvelope(value: unknown): SocketEnvelope | null { if (!Value.Check(SocketEnvelopeSchema, value)) { return null; diff --git a/src/runtime/slash-command-router.ts b/src/runtime/slash-command-router.ts deleted file mode 100644 index 3dcf992..0000000 --- a/src/runtime/slash-command-router.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type SlashCommandAgentMap = ReadonlyMap; - -export function normalizeSlashCommand(command: string): string { - return command.trim().toLowerCase(); -} - -export function createDefaultSlashCommandAgentMap(): Map { - return new Map([ - ["/wiggs", "data-analyst"], - ["/compliance", "compliance-helper"], - ]); -} - -export function resolveAgentIdForSlashCommand( - command: string, - commandAgentMap: SlashCommandAgentMap, -): string | null { - const normalized = normalizeSlashCommand(command); - if (normalized.length === 0) { - return null; - } - - return commandAgentMap.get(normalized) ?? null; -} diff --git a/src/runtime/trigger-normalizer.ts b/src/runtime/trigger-normalizer.ts deleted file mode 100644 index 0e9837a..0000000 --- a/src/runtime/trigger-normalizer.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { MessageEntrypoint } from "./ingress-binding-resolver.js"; -import type { - RunEntrypoint, - RunSurface, - RunTriggerKind, -} from "./run-lifecycle.js"; - -export type ProactiveTriggerKind = "cron" | "heartbeat"; - -export type NormalizedTrigger = { - triggerKind: RunTriggerKind; - surface: RunSurface; - entrypoint: RunEntrypoint; -}; - -export function normalizeSystemTrigger(): NormalizedTrigger { - return { - triggerKind: "system", - surface: "system", - entrypoint: "system", - }; -} - -export function normalizeSlackSlashCommandTrigger(): NormalizedTrigger { - return { - triggerKind: "message", - surface: "slack", - entrypoint: "slash_command", - }; -} - -export function normalizeSlackMessageTrigger( - entrypoint: MessageEntrypoint, -): NormalizedTrigger { - return { - triggerKind: "message", - surface: "slack", - entrypoint, - }; -} - -export function normalizeProactiveTrigger( - kind: ProactiveTriggerKind, -): NormalizedTrigger { - return { - triggerKind: kind, - surface: "system", - entrypoint: kind, - }; -} diff --git a/store/shared/connectors/duckdb.md b/store/shared/resources/duckdb.md similarity index 90% rename from store/shared/connectors/duckdb.md rename to store/shared/resources/duckdb.md index 7247fda..de836a8 100644 --- a/store/shared/connectors/duckdb.md +++ b/store/shared/resources/duckdb.md @@ -1,4 +1,4 @@ -# Connector: DuckDB (Jaffle Shop) +# Resource: DuckDB (Jaffle Shop) ## Path - `/Users/kevingalang/code/jaffle_shop_duckdb/jaffle_shop.duckdb` diff --git a/store/shared/resources/knowledge-docs.md b/store/shared/resources/knowledge-docs.md new file mode 100644 index 0000000..b6afed3 --- /dev/null +++ b/store/shared/resources/knowledge-docs.md @@ -0,0 +1,7 @@ +# Resource: Knowledge Docs + +Use this resource for policy/process decisions when factual numeric data is not required. + +Rules: +- Treat markdown guidance as the source of truth for policy interpretation. +- Quote policy text minimally and summarize the practical decision. diff --git a/store/shared/skills/duckdb-query.md b/store/shared/skills/duckdb-query.md new file mode 100644 index 0000000..9f286cd --- /dev/null +++ b/store/shared/skills/duckdb-query.md @@ -0,0 +1,8 @@ +# Shared Skill: DuckDB Query + +Use DuckDB resources to validate data-backed claims. + +Rules: +- Start with a narrow SQL query and expand only when needed. +- Prefer dbt model docs/schema context for table semantics. +- Include assumptions and caveats when translating query output to business language. diff --git a/store/shared/skills/knowledge-docs-review.md b/store/shared/skills/knowledge-docs-review.md new file mode 100644 index 0000000..052c571 --- /dev/null +++ b/store/shared/skills/knowledge-docs-review.md @@ -0,0 +1,8 @@ +# Shared Skill: Knowledge Docs Review + +Use `knowledge-docs` resources for policy and process recommendations. + +Rules: +- Ground recommendations in loaded policy docs before finalizing. +- Call out uncertainty when source docs are incomplete or conflicting. +- Keep final guidance concise and action-oriented for Slack delivery. diff --git a/tests/agents/capability-compiler.test.ts b/tests/agents/capability-compiler.test.ts new file mode 100644 index 0000000..24aee19 --- /dev/null +++ b/tests/agents/capability-compiler.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import { compileAgentCapabilities } from "../../agents/capability-compiler.js"; + +describe("compileAgentCapabilities", () => { + it("derives skills/resources/tool grants from capability bindings", () => { + const profile = compileAgentCapabilities({ + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + { + capability: "knowledge-docs-review-v1", + bindResources: { + docs: "policy-docs", + }, + }, + ], + }); + + expect(profile.requiredSkillIds).toEqual([ + "query-gravity", + "duckdb-query", + "knowledge-docs-review", + ]); + expect(profile.requiredResources).toEqual([ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ]); + expect(profile.toolPrimitives).toEqual(["read", "bash"]); + expect(profile.capabilityGuidance).toContain( + "- Capability `duckdb-analyst-v1` bound resources: warehouse=warehouse", + ); + }); + + it("keeps duplicate capability uses when bindResources differ", () => { + const profile = compileAgentCapabilities({ + resources: [ + { + id: "warehouse-a", + kind: "duckdb", + path: "/tmp/warehouse-a.duckdb", + }, + { + id: "warehouse-b", + kind: "duckdb", + path: "/tmp/warehouse-b.duckdb", + }, + ], + useCapabilities: [ + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-a", + }, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-b", + }, + }, + ], + }); + + expect(profile.capabilities).toHaveLength(2); + expect(profile.requiredResources.map((resource) => resource.id)).toEqual([ + "warehouse-a", + "warehouse-b", + ]); + }); +}); diff --git a/tests/agents/contracts.test.ts b/tests/agents/contracts.test.ts new file mode 100644 index 0000000..86228e7 --- /dev/null +++ b/tests/agents/contracts.test.ts @@ -0,0 +1,548 @@ +import { describe, expect, it } from "vitest"; +import { defineAgent, defineConfig } from "../../agents/contracts.js"; + +describe("defineConfig", () => { + it("normalizes and freezes the canonical config contract", () => { + const config = defineConfig({ + infra: { + database: { + urlEnvVar: " DATABASE_URL ", + }, + slack: { + appTokenEnvVar: " SLACK_APP_TOKEN ", + botTokenEnvVar: " SLACK_BOT_TOKEN ", + }, + modelProvider: { + provider: "anthropic", + apiKeyEnvVar: " ANTHROPIC_API_KEY ", + }, + }, + defaults: { + model: " claude-sonnet-4-5-20250929 ", + runtime: "host", + sessionMode: "thread", + quietHours: { + timezone: " America/Los_Angeles ", + startHour: 22, + endHour: 7, + daysOfWeek: [1, 2, 2, 3], + }, + }, + paths: { + sharedRoot: " store/shared ", + workspaceRoot: " workspace ", + }, + }); + + expect(config).toEqual({ + infra: { + database: { + urlEnvVar: "DATABASE_URL", + }, + slack: { + appTokenEnvVar: "SLACK_APP_TOKEN", + botTokenEnvVar: "SLACK_BOT_TOKEN", + }, + modelProvider: { + provider: "anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + }, + }, + defaults: { + model: "claude-sonnet-4-5-20250929", + runtime: "host", + sessionMode: "thread", + quietHours: { + enabled: true, + timezone: "America/Los_Angeles", + startHour: 22, + endHour: 7, + daysOfWeek: [1, 2, 3], + }, + }, + paths: { + sharedRoot: "store/shared", + workspaceRoot: "workspace", + }, + }); + expect(Object.isFrozen(config)).toBe(true); + }); +}); + +describe("defineAgent", () => { + it("normalizes listeners/resources/capabilities and applies listener defaults", () => { + const agent = defineAgent({ + id: " example-agent ", + name: " Example Agent ", + description: " Example description ", + model: " claude-sonnet-4-5-20250929 ", + resources: [ + { + id: " warehouse ", + kind: "duckdb", + path: " /tmp/warehouse.duckdb ", + }, + { + id: " policy-docs ", + kind: "knowledge-docs", + }, + ], + runtime: "host", + listen: [ + { + id: " slash-route ", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: " /Wiggs ", + }, + }, + { + id: " dm-route ", + kind: "message", + surface: "slack", + entrypoint: "direct_message", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: " warehouse ", + }, + }, + { + capability: "knowledge-docs-review-v1", + bindResources: { + docs: "policy-docs", + }, + }, + ], + }); + + expect(agent.id).toBe("example-agent"); + expect(agent.name).toBe("Example Agent"); + expect(agent.description).toBe("Example description"); + expect(agent.model).toBe("claude-sonnet-4-5-20250929"); + expect(agent.resources).toEqual([ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ]); + expect(agent.useCapabilities).toEqual([ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + { + capability: "knowledge-docs-review-v1", + bindResources: { + docs: "policy-docs", + }, + }, + ]); + expect(agent.listen).toEqual([ + { + id: "slash-route", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + sessionMode: "thread", + enabled: true, + match: { + command: "/wiggs", + }, + }, + { + id: "dm-route", + kind: "message", + surface: "slack", + entrypoint: "direct_message", + sessionMode: "main", + enabled: true, + }, + ]); + expect(Object.isFrozen(agent)).toBe(true); + }); + + it("throws for slash listeners without a slash command matcher", () => { + expect(() => + defineAgent({ + id: "broken-agent", + name: "Broken Agent", + listen: [ + { + id: "broken-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + }), + ).toThrow(/requires match\.command/); + }); + + it("throws for duplicate resource ids", () => { + expect(() => + defineAgent({ + id: "duplicate-resource-agent", + name: "Duplicate Resource Agent", + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/one.duckdb", + }, + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/two.duckdb", + }, + ], + listen: [ + { + id: "duplicate-resource-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/dup", + }, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + }), + ).toThrow(/duplicates resource id/i); + }); + + it("throws when a capability references an unknown resource id", () => { + expect(() => + defineAgent({ + id: "unknown-resource-reference-agent", + name: "Unknown Resource Reference Agent", + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/current.duckdb", + }, + ], + listen: [ + { + id: "unknown-resource-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/unknown", + }, + }, + ], + useCapabilities: [ + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "missing", + }, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/references unknown resource id/i); + }); + + it("throws when deprecated top-level duckdbPath is provided", () => { + expect(() => + defineAgent({ + id: "deprecated-duckdb-path-agent", + name: "Deprecated DuckDB Path Agent", + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/current.duckdb", + }, + ], + duckdbPath: "/tmp/legacy.duckdb", + listen: [ + { + id: "deprecated-duckdb-path-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/legacy", + }, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/duckdbPath has been removed/i); + }); + + it("throws when deprecated connectors field is provided", () => { + expect(() => + defineAgent({ + id: "deprecated-connectors-agent", + name: "Deprecated Connectors Agent", + connectors: [ + { + type: "duckdb", + path: "/tmp/legacy.duckdb", + }, + ], + listen: [ + { + id: "deprecated-connectors-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/legacy-connectors", + }, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/connectors has been renamed to resources/i); + }); + + it("throws when deprecated capabilities field is provided", () => { + expect(() => + defineAgent({ + id: "deprecated-capabilities-agent", + name: "Deprecated Capabilities Agent", + listen: [ + { + id: "deprecated-capabilities-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/legacy-capabilities", + }, + }, + ], + capabilities: [ + { + use: "query-gravity-v1", + bindResources: {}, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/agent\.capabilities has been renamed to useCapabilities/i); + }); + + it("throws when deprecated tools field is provided", () => { + expect(() => + defineAgent({ + id: "deprecated-tools-agent", + name: "Deprecated Tools Agent", + listen: [ + { + id: "deprecated-tools-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/legacy-tools", + }, + }, + ], + tools: ["query-gravity"], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/tools has been replaced by useCapabilities/i); + }); + + it("throws when deprecated skills field is provided", () => { + expect(() => + defineAgent({ + id: "deprecated-skills-agent", + name: "Deprecated Skills Agent", + listen: [ + { + id: "deprecated-skills-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/legacy-skills", + }, + }, + ], + skills: [ + { + skill: "query-gravity", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + ], + } as unknown as Parameters[0]), + ).toThrow(/skills has been replaced by useCapabilities/i); + }); + + it("allows reusing a capability with different bindResources mappings", () => { + const agent = defineAgent({ + id: "multi-warehouse-agent", + name: "Multi Warehouse Agent", + resources: [ + { + id: "warehouse-a", + kind: "duckdb", + path: "/tmp/warehouse-a.duckdb", + }, + { + id: "warehouse-b", + kind: "duckdb", + path: "/tmp/warehouse-b.duckdb", + }, + ], + listen: [ + { + id: "multi-warehouse-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/multi", + }, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-a", + }, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-b", + }, + }, + ], + }); + + expect(agent.useCapabilities).toEqual([ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-a", + }, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse-b", + }, + }, + ]); + }); + + it("throws when capability bindings are duplicated with the same signature", () => { + expect(() => + defineAgent({ + id: "duplicate-capability-binding-agent", + name: "Duplicate Capability Binding Agent", + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/warehouse.duckdb", + }, + ], + listen: [ + { + id: "duplicate-capability-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/dup-cap", + }, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + ], + }), + ).toThrow(/duplicates capability binding/i); + }); +}); diff --git a/tests/agents/index.test.ts b/tests/agents/index.test.ts new file mode 100644 index 0000000..89e28fa --- /dev/null +++ b/tests/agents/index.test.ts @@ -0,0 +1,398 @@ +import { describe, expect, it } from "vitest"; +import { defineAgent, defineConfig } from "../../agents/contracts.js"; +import { agentRegistry, createAgentRegistry } from "../../agents/index.js"; + +const testConfig = defineConfig({ + infra: { + database: { + urlEnvVar: "DATABASE_URL", + }, + slack: { + appTokenEnvVar: "SLACK_APP_TOKEN", + botTokenEnvVar: "SLACK_BOT_TOKEN", + }, + modelProvider: { + provider: "anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + }, + }, + defaults: { + model: "claude-sonnet-4-5-20250929", + runtime: "host", + sessionMode: "thread", + }, + paths: { + sharedRoot: "store/shared", + workspaceRoot: "workspace", + }, +}); + +describe("agentRegistry", () => { + it("registers built-in agents with deterministic slash routes", () => { + expect(agentRegistry.agentsById.has("data-analyst")).toBe(true); + expect(agentRegistry.agentsById.has("compliance-helper")).toBe(true); + + expect(agentRegistry.slashCommandListeners.get("/wiggs")).toEqual({ + agentId: "data-analyst", + listenerId: "slack-wiggs-slash", + command: "/wiggs", + sessionMode: "thread", + entrypoint: "slash_command", + match: { + command: "/wiggs", + }, + trigger: { + triggerKind: "message", + surface: "slack", + entrypoint: "slash_command", + runIdPattern: "slack:{sourceEventId}", + }, + }); + expect(agentRegistry.slashCommandListeners.get("/compliance")).toEqual({ + agentId: "compliance-helper", + listenerId: "slack-compliance-slash", + command: "/compliance", + sessionMode: "thread", + entrypoint: "slash_command", + match: { + command: "/compliance", + }, + trigger: { + triggerKind: "message", + surface: "slack", + entrypoint: "slash_command", + runIdPattern: "slack:{sourceEventId}", + }, + }); + + expect( + agentRegistry.compiledDeclarations.ingress.slashCommands["/wiggs"], + ).toMatchObject({ + agentId: "data-analyst", + listenerId: "slack-wiggs-slash", + command: "/wiggs", + trigger: { + triggerKind: "message", + surface: "slack", + entrypoint: "slash_command", + runIdPattern: "slack:{sourceEventId}", + }, + }); + expect(agentRegistry.compiledDeclarations.ingress.listeners.length).toBe(8); + expect( + agentRegistry.compiledDeclarations.ingress.messageByEntrypoint.app_mention + .length, + ).toBe(2); + expect( + agentRegistry.compiledDeclarations.ingress.messageByEntrypoint.thread_reply + .length, + ).toBe(2); + expect( + agentRegistry.compiledDeclarations.ingress.messageByEntrypoint.direct_message + .length, + ).toBe(2); + expect(agentRegistry.compiledDeclarations.proactive.triggers).toEqual([]); + expect(agentRegistry.compiledDeclarations.sessions.dimensions.length).toBe(8); + expect(agentRegistry.compiledDeclarations.triggerDimensions.length).toBe(8); + }); +}); + +describe("createAgentRegistry", () => { + it("throws when agent IDs collide", () => { + const first = defineAgent({ + id: "duplicate-agent", + name: "Duplicate One", + listen: [ + { + id: "slash-one", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/one", + }, + }, + ], + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }); + const second = defineAgent({ + id: "duplicate-agent", + name: "Duplicate Two", + listen: [ + { + id: "slash-two", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/two", + }, + }, + ], + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }); + + expect(() => + createAgentRegistry({ + config: testConfig, + agents: [first, second], + }), + ).toThrow(/duplicate agent id/i); + }); + + it("throws when slash commands collide across agents", () => { + const first = defineAgent({ + id: "alpha", + name: "Alpha", + listen: [ + { + id: "alpha-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/shared", + }, + }, + ], + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }); + const second = defineAgent({ + id: "beta", + name: "Beta", + listen: [ + { + id: "beta-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/shared", + }, + }, + ], + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }); + + expect(() => + createAgentRegistry({ + config: testConfig, + agents: [first, second], + }), + ).toThrow(/slash command collision/i); + }); + + it("compiles proactive/session dimensions and trigger identities", () => { + const compiled = createAgentRegistry({ + config: testConfig, + agents: [ + defineAgent({ + id: "alpha", + name: "Alpha", + quietHours: { + timezone: "America/Los_Angeles", + startHour: 22, + endHour: 7, + }, + listen: [ + { + id: "alpha-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/alpha", + }, + }, + ], + proactive: { + deliveryDefaults: { + surface: "slack", + mode: "dm", + userId: "U999", + }, + triggers: [ + { + id: "nightly", + kind: "cron", + schedule: "0 9 * * *", + prompt: "run nightly", + sessionMode: "thread", + }, + ], + }, + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }), + ], + }).compiledDeclarations; + + expect(compiled.proactive.triggers).toEqual([ + { + agentId: "alpha", + triggerId: "nightly", + kind: "cron", + schedule: "0 9 * * *", + prompt: "run nightly", + sessionMode: "main", + delivery: { + surface: "slack", + mode: "dm", + userId: "U999", + }, + quietHours: { + enabled: true, + timezone: "America/Los_Angeles", + startHour: 22, + endHour: 7, + }, + trigger: { + triggerKind: "cron", + surface: "system", + entrypoint: "cron", + runIdPattern: "{sourceEventId}", + }, + }, + ]); + expect(compiled.sessions.dimensions).toContainEqual({ + agentId: "alpha", + sourceKind: "proactive", + sourceId: "nightly", + sessionMode: "main", + sessionKeyPatterns: ["{agentId}:main"], + trigger: { + triggerKind: "cron", + surface: "system", + entrypoint: "cron", + runIdPattern: "{sourceEventId}", + }, + }); + }); + + it("omits proactive quiet hours when policy is explicitly disabled", () => { + const configWithDisabledQuietHours = defineConfig({ + infra: { + database: { + urlEnvVar: "DATABASE_URL", + }, + slack: { + appTokenEnvVar: "SLACK_APP_TOKEN", + botTokenEnvVar: "SLACK_BOT_TOKEN", + }, + modelProvider: { + provider: "anthropic", + apiKeyEnvVar: "ANTHROPIC_API_KEY", + }, + }, + defaults: { + model: "claude-sonnet-4-5-20250929", + runtime: "host", + sessionMode: "thread", + quietHours: { + enabled: false, + timezone: "UTC", + startHour: 0, + endHour: 0, + }, + }, + paths: { + sharedRoot: "store/shared", + workspaceRoot: "workspace", + }, + }); + + const compiled = createAgentRegistry({ + config: configWithDisabledQuietHours, + agents: [ + defineAgent({ + id: "alpha", + name: "Alpha", + listen: [ + { + id: "alpha-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/alpha", + }, + }, + ], + proactive: { + deliveryDefaults: { + surface: "slack", + mode: "dm", + userId: "U999", + }, + triggers: [ + { + id: "heartbeat", + kind: "heartbeat", + intervalSeconds: 300, + prompt: "ping", + }, + ], + }, + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }), + ], + }).compiledDeclarations; + + expect(compiled.proactive.triggers).toEqual([ + { + agentId: "alpha", + triggerId: "heartbeat", + kind: "heartbeat", + intervalSeconds: 300, + prompt: "ping", + sessionMode: "isolated", + delivery: { + surface: "slack", + mode: "dm", + userId: "U999", + }, + trigger: { + triggerKind: "heartbeat", + surface: "system", + entrypoint: "heartbeat", + runIdPattern: "{sourceEventId}", + }, + }, + ]); + }); + + it("throws when a proactive trigger cannot resolve a delivery target", () => { + expect(() => + createAgentRegistry({ + config: testConfig, + agents: [ + defineAgent({ + id: "alpha", + name: "Alpha", + listen: [ + { + id: "alpha-slash", + kind: "message", + surface: "slack", + entrypoint: "slash_command", + match: { + command: "/alpha", + }, + }, + ], + proactive: { + triggers: [ + { + id: "missing-delivery", + kind: "heartbeat", + intervalSeconds: 300, + prompt: "ping", + }, + ], + }, + useCapabilities: [{ capability: "query-gravity-v1", bindResources: {} }], + }), + ], + }), + ).toThrow(/missing delivery and proactive deliveryDefaults/i); + }); +}); diff --git a/tests/resources/registry.test.ts b/tests/resources/registry.test.ts new file mode 100644 index 0000000..66edab5 --- /dev/null +++ b/tests/resources/registry.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { loadResourceContributions } from "../../src/resources/registry.js"; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map(async (tempRoot) => { + await rm(tempRoot, { recursive: true, force: true }); + }), + ); +}); + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +describe("loadResourceContributions", () => { + it("loads typed resource contributions from static registry plugins", async () => { + const tempRoot = await createTempRoot("gravity-resources-"); + const sharedRoot = path.join(tempRoot, "store", "shared"); + const sharedResourcesDir = path.join(sharedRoot, "resources"); + await mkdir(sharedResourcesDir, { recursive: true }); + await writeFile( + path.join(sharedResourcesDir, "duckdb-reference.md"), + "DuckDB resource reference", + "utf8", + ); + await writeFile( + path.join(sharedResourcesDir, "knowledge-docs-guide.md"), + "Knowledge docs resource guide", + "utf8", + ); + await writeFile( + path.join(sharedResourcesDir, "unrelated.md"), + "Should not be included", + "utf8", + ); + + const duckdbProjectDir = path.join(tempRoot, "warehouse"); + const duckdbPath = path.join(duckdbProjectDir, "warehouse.duckdb"); + const duckdbModelsDir = path.join(duckdbProjectDir, "models", "finance"); + await mkdir(duckdbModelsDir, { recursive: true }); + await writeFile(duckdbPath, "", "utf8"); + await writeFile( + path.join(duckdbModelsDir, "schema.yml"), + "version: 2\nmodels:\n - name: revenue", + "utf8", + ); + await writeFile( + path.join(duckdbModelsDir, "README.md"), + "Model notes", + "utf8", + ); + + const contributions = await loadResourceContributions({ + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: duckdbPath, + }, + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ], + cwd: tempRoot, + sharedRoot: "store/shared", + }); + + expect(contributions).toHaveLength(2); + + const duckdbContribution = contributions.find( + (contribution) => contribution.resourceKind === "duckdb", + ); + expect(duckdbContribution).toBeDefined(); + expect(duckdbContribution?.resourceId).toBe("warehouse"); + expect(duckdbContribution?.guidance).toContain( + "- Resource `warehouse` (`duckdb`): query structured data via SQL when facts need validation.", + ); + expect(duckdbContribution?.sharedDocs).toHaveLength(1); + expect(duckdbContribution?.sharedDocs[0]?.filePath).toContain( + "duckdb-reference.md", + ); + expect(duckdbContribution?.contextDocs.length).toBe(2); + + const knowledgeDocsContribution = contributions.find( + (contribution) => contribution.resourceKind === "knowledge-docs", + ); + expect(knowledgeDocsContribution).toBeDefined(); + expect(knowledgeDocsContribution?.resourceId).toBe("policy-docs"); + expect(knowledgeDocsContribution?.sharedDocs).toHaveLength(1); + expect(knowledgeDocsContribution?.sharedDocs[0]?.filePath).toContain( + "knowledge-docs-guide.md", + ); + expect(knowledgeDocsContribution?.contextDocs).toEqual([]); + }); + + it("fails open for missing duckdb project metadata", async () => { + const tempRoot = await createTempRoot("gravity-resources-missing-"); + + const contributions = await loadResourceContributions({ + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: path.join(tempRoot, "missing", "warehouse.duckdb"), + }, + ], + cwd: tempRoot, + sharedRoot: "store/shared", + }); + + expect(contributions).toHaveLength(1); + expect(contributions[0]?.resourceKind).toBe("duckdb"); + expect(contributions[0]?.contextDocs).toEqual([]); + }); +}); diff --git a/tests/runtime/agent-config.test.ts b/tests/runtime/agent-config.test.ts deleted file mode 100644 index ff00091..0000000 --- a/tests/runtime/agent-config.test.ts +++ /dev/null @@ -1,162 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseAgentConfig } from "../../src/runtime/agent-config.js"; - -describe("parseAgentConfig", () => { - it("normalizes and trims supported config blocks", () => { - const parsed = parseAgentConfig({ - connector: " duckdb ", - duckdb_path: " /tmp/example.duckdb ", - ingressBindings: [ - { - id: " binding-1 ", - kind: "message", - surface: "slack", - entrypoint: "direct_message", - match: { - channelId: " D123 ", - userId: " U123 ", - isDirectMessage: true, - }, - }, - ], - deliveryDefaults: { - surface: "slack", - mode: "channel_thread", - channelId: " C555 ", - }, - proactiveTriggers: [ - { - id: " hourly ", - kind: "heartbeat", - intervalSeconds: 900.9, - prompt: " ping ", - delivery: { - surface: "slack", - mode: "dm", - userId: " U777 ", - }, - }, - ], - policy: { - quietHours: { - timezone: " America/Los_Angeles ", - startHour: 22, - endHour: 7, - daysOfWeek: [1, 2, 2, 3], - }, - }, - }); - - expect(parsed).toEqual({ - connector: "duckdb", - duckdb_path: "/tmp/example.duckdb", - ingressBindings: [ - { - id: "binding-1", - kind: "message", - surface: "slack", - entrypoint: "direct_message", - enabled: true, - match: { - channelId: "D123", - userId: "U123", - isDirectMessage: true, - }, - }, - ], - deliveryDefaults: { - surface: "slack", - mode: "channel_thread", - channelId: "C555", - }, - proactiveTriggers: [ - { - id: "hourly", - kind: "heartbeat", - intervalSeconds: 900, - prompt: "ping", - enabled: true, - delivery: { - surface: "slack", - mode: "dm", - userId: "U777", - }, - }, - ], - policy: { - quietHours: { - enabled: true, - timezone: "America/Los_Angeles", - startHour: 22, - endHour: 7, - daysOfWeek: [1, 2, 3], - }, - }, - }); - }); - - it("fails closed when any config block is invalid", () => { - const warnings: string[] = []; - - const parsed = parseAgentConfig( - { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "app_mention", - }, - { - kind: "bad-kind", - surface: "slack", - entrypoint: "app_mention", - }, - ], - proactiveTriggers: [ - { - id: "ok-cron", - kind: "cron", - schedule: "0 9 * * *", - prompt: "run", - }, - { - id: "bad-heartbeat", - kind: "heartbeat", - intervalSeconds: 2, - prompt: "skip", - }, - ], - }, - { - warn: (line) => { - warnings.push(line); - }, - context: "agentId=test-agent", - }, - ); - - expect(parsed).toEqual({}); - expect(warnings.length).toBeGreaterThanOrEqual(1); - expect(warnings[0]).toContain("agentId=test-agent"); - }); - - it("returns an empty config for non-object values", () => { - expect(parseAgentConfig(null)).toEqual({}); - expect(parseAgentConfig("invalid")).toEqual({}); - expect(parseAgentConfig(42)).toEqual({}); - }); - - it("fails closed for invalid quiet-hours policy", () => { - const parsed = parseAgentConfig({ - policy: { - quietHours: { - timezone: " ", - startHour: 22, - endHour: 7, - }, - }, - }); - - expect(parsed).toEqual({}); - }); -}); diff --git a/tests/runtime/config.test.ts b/tests/runtime/config.test.ts index 56374d9..c065d6d 100644 --- a/tests/runtime/config.test.ts +++ b/tests/runtime/config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { runtimeConfig } from "../../agents/index.js"; import { loadConfig } from "../../src/runtime/config.js"; describe("loadConfig", () => { @@ -17,9 +18,10 @@ describe("loadConfig", () => { }); it("uses explicit environment overrides", () => { + const databaseUrlEnvVar = runtimeConfig.infra.database.urlEnvVar; const config = loadConfig({ GRAVITY_ENV: "test", - DATABASE_URL: "postgres://custom-url", + [databaseUrlEnvVar]: "postgres://custom-url", GRAVITY_LIVENESS_INTERVAL_SECONDS: "45", }); @@ -34,10 +36,13 @@ describe("loadConfig", () => { }); it("normalizes optional Slack tokens", () => { + const slackAppTokenEnvVar = runtimeConfig.infra.slack.appTokenEnvVar; + const slackBotTokenEnvVar = runtimeConfig.infra.slack.botTokenEnvVar; + const modelApiKeyEnvVar = runtimeConfig.infra.modelProvider.apiKeyEnvVar; const config = loadConfig({ - SLACK_APP_TOKEN: " xapp-abc ", - SLACK_BOT_TOKEN: "", - ANTHROPIC_API_KEY: " key-123 ", + [slackAppTokenEnvVar]: " xapp-abc ", + [slackBotTokenEnvVar]: "", + [modelApiKeyEnvVar]: " key-123 ", }); expect(config.slackAppToken).toBe("xapp-abc"); diff --git a/tests/runtime/context-assembler.test.ts b/tests/runtime/context-assembler.test.ts new file mode 100644 index 0000000..e346723 --- /dev/null +++ b/tests/runtime/context-assembler.test.ts @@ -0,0 +1,179 @@ +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { compileAgentCapabilities } from "../../agents/capability-compiler.js"; +import { assembleTurnContext } from "../../src/runtime/context-assembler.js"; + +const tempRoots: string[] = []; + +afterEach(async () => { + await Promise.all( + tempRoots.splice(0).map(async (tempRoot) => { + await rm(tempRoot, { recursive: true, force: true }); + }), + ); +}); + +async function createTempRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(os.tmpdir(), prefix)); + tempRoots.push(root); + return root; +} + +describe("assembleTurnContext", () => { + it("assembles duckdb-focused context and blank prompt normalization", async () => { + const tempRoot = await createTempRoot("gravity-context-duckdb-"); + const sharedRoot = path.join(tempRoot, "store", "shared"); + const sharedSkillsDir = path.join(sharedRoot, "skills"); + const sharedResourcesDir = path.join(sharedRoot, "resources"); + const agentSkillsDir = path.join(tempRoot, "store", "agents", "alpha", "skills"); + const agentMemoryDir = path.join(tempRoot, "store", "agents", "alpha", "memory"); + + await mkdir(sharedSkillsDir, { recursive: true }); + await mkdir(sharedResourcesDir, { recursive: true }); + await mkdir(agentSkillsDir, { recursive: true }); + await mkdir(agentMemoryDir, { recursive: true }); + + await writeFile(path.join(sharedSkillsDir, "query-gravity.md"), "Shared skill", "utf8"); + await writeFile(path.join(sharedSkillsDir, "duckdb-query.md"), "DuckDB skill", "utf8"); + await writeFile( + path.join(sharedResourcesDir, "duckdb-reference.md"), + "DuckDB resource docs", + "utf8", + ); + await writeFile(path.join(agentSkillsDir, "alpha.md"), "Agent skill", "utf8"); + await writeFile(path.join(agentMemoryDir, "MEMORY.md"), "Prior memory", "utf8"); + + const duckdbProjectDir = path.join(tempRoot, "warehouse"); + const duckdbPath = path.join(duckdbProjectDir, "warehouse.duckdb"); + const modelsDir = path.join(duckdbProjectDir, "models"); + await mkdir(modelsDir, { recursive: true }); + await writeFile(duckdbPath, "", "utf8"); + await writeFile(path.join(modelsDir, "schema.yml"), "version: 2", "utf8"); + + const context = await assembleTurnContext({ + cwd: tempRoot, + sharedRoot: "store/shared", + prompt: " ", + agent: { + id: "alpha", + name: "Alpha", + description: "Data helper", + capabilityProfile: compileAgentCapabilities({ + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: duckdbPath, + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "duckdb-analyst-v1", + bindResources: { + warehouse: "warehouse", + }, + }, + ], + }), + skillsPath: "store/agents/alpha/skills", + memoryPath: "store/agents/alpha/memory", + }, + }); + + expect(context.normalizedPrompt).toContain("DuckDB business questions"); + expect(context.systemPrompt).toContain("You are Alpha (alpha)."); + expect(context.systemPrompt).toContain("Active capabilities:"); + expect(context.systemPrompt).toContain( + "- `duckdb-analyst-v1`: execute SQL-backed analysis against the bound DuckDB warehouse resource.", + ); + expect(context.systemPrompt).toContain("Resource guidance:"); + expect(context.systemPrompt).toContain( + "- Resource `warehouse` (`duckdb`): query structured data via SQL when facts need validation.", + ); + expect(context.systemPrompt).toContain("dbt schema/docs context loaded this turn:"); + expect(context.systemPrompt).toContain("schema.yml"); + expect(context.systemPrompt).toContain("Shared skills loaded this turn:"); + expect(context.systemPrompt).toContain("Agent memory:"); + }); + + it("assembles non-duckdb context with generic prompt fallback", async () => { + const tempRoot = await createTempRoot("gravity-context-knowledge-"); + const sharedRoot = path.join(tempRoot, "store", "shared"); + const sharedSkillsDir = path.join(sharedRoot, "skills"); + const sharedResourcesDir = path.join(sharedRoot, "resources"); + await mkdir(sharedSkillsDir, { recursive: true }); + await mkdir(sharedResourcesDir, { recursive: true }); + + await writeFile(path.join(sharedSkillsDir, "query-gravity.md"), "Shared skill", "utf8"); + await writeFile( + path.join(sharedSkillsDir, "knowledge-docs-review.md"), + "Knowledge docs skill", + "utf8", + ); + await writeFile( + path.join(sharedResourcesDir, "knowledge-docs-guide.md"), + "Knowledge docs guidance", + "utf8", + ); + + const context = await assembleTurnContext({ + cwd: tempRoot, + sharedRoot: "store/shared", + prompt: "", + agent: { + id: "beta", + name: "Beta", + description: null, + capabilityProfile: compileAgentCapabilities({ + resources: [ + { + id: "warehouse", + kind: "duckdb", + path: "/tmp/unbound.duckdb", + }, + { + id: "policy-docs", + kind: "knowledge-docs", + }, + ], + useCapabilities: [ + { + capability: "query-gravity-v1", + bindResources: {}, + }, + { + capability: "knowledge-docs-review-v1", + bindResources: { + docs: "policy-docs", + }, + }, + ], + }), + skillsPath: null, + memoryPath: null, + }, + }); + + expect(context.normalizedPrompt).toContain( + "next steps based on available capabilities/resources", + ); + expect(context.systemPrompt).toContain( + "Resource-specific schema/docs context loaded this turn:", + ); + expect(context.systemPrompt).toContain( + "- Resource `policy-docs` (`knowledge-docs`): use loaded markdown docs for policy/process guidance before final recommendations.", + ); + expect(context.systemPrompt).not.toContain( + "- Resource `warehouse` (`duckdb`): query structured data via SQL when facts need validation.", + ); + expect(context.systemPrompt).not.toContain( + "- Use DuckDB for factual claims when a query is needed.", + ); + }); +}); diff --git a/tests/runtime/executor-manager.test.ts b/tests/runtime/executor-manager.test.ts new file mode 100644 index 0000000..07f8a2c --- /dev/null +++ b/tests/runtime/executor-manager.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { createExecutorManager } from "../../src/runtime/executor-manager.js"; + +describe("createExecutorManager", () => { + it("resolves host executor by default", () => { + const manager = createExecutorManager(); + const executor = manager.resolve("host"); + + expect(executor.id).toBe("host"); + expect(executor.runtime).toBe("host"); + expect(executor.createTools(process.cwd(), ["read", "bash"]).length).toBe(2); + expect(executor.createTools(process.cwd(), ["read"]).length).toBe(1); + expect(executor.createTools(process.cwd(), []).length).toBe(0); + }); + + it("fails closed when sandbox runtime is requested while disabled", () => { + const manager = createExecutorManager({ enableSandbox: false }); + expect(() => manager.resolve("sandbox")).toThrow( + /sandbox runtime requested/i, + ); + }); + + it("returns scaffold sandbox executor when explicitly enabled", () => { + const manager = createExecutorManager({ + enableSandbox: true, + log: () => undefined, + }); + const executor = manager.resolve("sandbox"); + + expect(executor.id).toBe("sandbox-scaffold-disabled"); + expect(executor.runtime).toBe("sandbox"); + expect(() => executor.createTools(process.cwd(), ["read"])).toThrow( + /scaffold is disabled/i, + ); + }); +}); diff --git a/tests/runtime/ingress-binding-resolver.test.ts b/tests/runtime/ingress-binding-resolver.test.ts deleted file mode 100644 index 1b8c15e..0000000 --- a/tests/runtime/ingress-binding-resolver.test.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveMessageIngress, - type ActiveAgentIngressRow, -} from "../../src/runtime/ingress-binding-resolver.js"; -import type { InboundSlackMessage } from "../../src/runtime/slack-transport.js"; - -function createBaseMessage( - overrides: Partial = {}, -): InboundSlackMessage { - return { - surface: "app_mention", - sourceEventId: "evt-1", - channelId: "C123", - threadTs: "1700000000.1", - messageTs: "1700000000.1", - userId: "U123", - text: "hello", - isDirectMessage: false, - ...overrides, - }; -} - -function createAgent( - overrides: Partial = {}, -): ActiveAgentIngressRow { - return { - id: "data-analyst", - channel_id: "C123", - config: {}, - ...overrides, - }; -} - -describe("resolveMessageIngress", () => { - it("uses ingress bindings for app mentions", () => { - const message = createBaseMessage(); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "app_mention", - sessionMode: "thread", - enabled: true, - }, - ], - }, - }), - ]; - - expect(resolveMessageIngress(message, agents)).toEqual({ - agentId: "data-analyst", - entrypoint: "app_mention", - sessionMode: "thread", - route: "binding", - }); - }); - - it("uses direct message binding with main session mode", () => { - const message = createBaseMessage({ - surface: "message", - isDirectMessage: true, - channelId: "D123", - threadTs: "1700000001.1", - messageTs: "1700000001.1", - }); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "direct_message", - sessionMode: "main", - enabled: true, - match: { userId: "U123" }, - }, - ], - }, - }), - ]; - - expect(resolveMessageIngress(message, agents)).toEqual({ - agentId: "data-analyst", - entrypoint: "direct_message", - sessionMode: "main", - route: "binding", - }); - }); - - it("treats DM thread replies as thread_reply entrypoint", () => { - const message = createBaseMessage({ - surface: "message", - isDirectMessage: true, - channelId: "D123", - threadTs: "1700000001.1", - messageTs: "1700000001.2", - }); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - id: "data-analyst", - channel_id: null, - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "thread_reply", - sessionMode: "thread", - enabled: true, - match: { threadOwnedByAgent: true }, - }, - ], - }, - }), - ]; - - expect( - resolveMessageIngress(message, agents, { threadOwnerAgentId: "data-analyst" }), - ).toEqual({ - agentId: "data-analyst", - entrypoint: "thread_reply", - sessionMode: "thread", - route: "binding", - }); - }); - - it("returns null for thread replies when no binding exists", () => { - const message = createBaseMessage({ - surface: "message", - threadTs: "1700000000.1", - messageTs: "1700000000.2", - }); - const agents: ActiveAgentIngressRow[] = [createAgent()]; - - expect(resolveMessageIngress(message, agents)).toBeNull(); - }); - - it("prefers channel-affinity agent when multiple bindings match", () => { - const message = createBaseMessage({ - surface: "app_mention", - channelId: "C-WIGGS", - }); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - id: "compliance-helper", - channel_id: "C-COMPLIANCE", - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "app_mention", - sessionMode: "thread", - enabled: true, - }, - ], - }, - }), - createAgent({ - id: "data-analyst", - channel_id: "C-WIGGS", - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "app_mention", - sessionMode: "thread", - enabled: true, - }, - ], - }, - }), - ]; - - expect(resolveMessageIngress(message, agents)).toMatchObject({ - agentId: "data-analyst", - entrypoint: "app_mention", - route: "binding", - }); - }); - - it("enforces threadOwnedByAgent against known thread owner", () => { - const message = createBaseMessage({ - surface: "message", - channelId: "C-WIGGS", - threadTs: "1700000000.1", - messageTs: "1700000000.2", - }); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - id: "compliance-helper", - channel_id: "C-COMPLIANCE", - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "thread_reply", - sessionMode: "thread", - enabled: true, - match: { threadOwnedByAgent: true }, - }, - ], - }, - }), - createAgent({ - id: "data-analyst", - channel_id: "C-WIGGS", - config: { - ingressBindings: [ - { - kind: "message", - surface: "slack", - entrypoint: "thread_reply", - sessionMode: "thread", - enabled: true, - match: { threadOwnedByAgent: true }, - }, - ], - }, - }), - ]; - - expect( - resolveMessageIngress(message, agents, { threadOwnerAgentId: "data-analyst" }), - ).toMatchObject({ - agentId: "data-analyst", - entrypoint: "thread_reply", - route: "binding", - }); - }); - - it("returns null for unsupported top-level channel message events", () => { - const message = createBaseMessage({ - surface: "message", - threadTs: "1700000000.1", - messageTs: "1700000000.1", - }); - const agents: ActiveAgentIngressRow[] = [createAgent()]; - - expect(resolveMessageIngress(message, agents)).toBeNull(); - }); - - it("ignores malformed binding entries without throwing", () => { - const message = createBaseMessage(); - const agents: ActiveAgentIngressRow[] = [ - createAgent({ - config: { - ingressBindings: [ - null, - { - kind: "message", - surface: "slack", - entrypoint: "app_mention", - sessionMode: "thread", - enabled: true, - }, - ], - } as unknown as ActiveAgentIngressRow["config"], - }), - ]; - - expect(() => resolveMessageIngress(message, agents)).not.toThrow(); - expect(resolveMessageIngress(message, agents)).toEqual({ - agentId: "data-analyst", - entrypoint: "app_mention", - sessionMode: "thread", - route: "binding", - }); - }); -}); diff --git a/tests/runtime/proactive-trigger-resolver.test.ts b/tests/runtime/proactive-trigger-resolver.test.ts deleted file mode 100644 index 489abb7..0000000 --- a/tests/runtime/proactive-trigger-resolver.test.ts +++ /dev/null @@ -1,266 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - resolveProactiveTriggers, - type ActiveAgentProactiveRow, -} from "../../src/runtime/proactive-trigger-resolver.js"; - -describe("resolveProactiveTriggers", () => { - it("resolves cron and heartbeat triggers with explicit delivery targets", () => { - const triggers = resolveProactiveTriggers([ - { - id: "data-analyst", - channel_id: "C123", - config: { - proactiveTriggers: [ - { - id: "daily-metrics", - kind: "cron", - schedule: "0 9 * * *", - prompt: "Run daily metrics check", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C555", - }, - }, - { - id: "founder-heartbeat", - kind: "heartbeat", - intervalSeconds: 1800, - prompt: "Check anomalies", - sessionMode: "thread", - delivery: { - surface: "slack", - mode: "dm", - userId: "U123", - }, - }, - ], - }, - } satisfies ActiveAgentProactiveRow, - ]); - - expect(triggers).toEqual([ - { - agentId: "data-analyst", - triggerId: "daily-metrics", - kind: "cron", - schedule: "0 9 * * *", - prompt: "Run daily metrics check", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C555", - }, - }, - { - agentId: "data-analyst", - triggerId: "founder-heartbeat", - kind: "heartbeat", - intervalSeconds: 1800, - prompt: "Check anomalies", - // thread sessions are invalid for DM delivery and should fall back to main - sessionMode: "main", - delivery: { - surface: "slack", - mode: "dm", - userId: "U123", - }, - }, - ]); - }); - - it("uses delivery defaults and channel fallback when trigger delivery is omitted", () => { - const triggers = resolveProactiveTriggers([ - { - id: "with-defaults", - channel_id: "C111", - config: { - deliveryDefaults: { - surface: "slack", - mode: "dm", - userId: "U999", - }, - proactiveTriggers: [ - { - id: "heartbeat-defaults", - kind: "heartbeat", - intervalSeconds: 900, - prompt: "Monitor queue depth", - }, - ], - }, - } satisfies ActiveAgentProactiveRow, - { - id: "channel-fallback", - channel_id: "C222", - config: { - proactiveTriggers: [ - { - id: "cron-fallback", - kind: "cron", - schedule: "*/10 * * * *", - prompt: "Post summary", - }, - ], - }, - } satisfies ActiveAgentProactiveRow, - ]); - - expect(triggers).toEqual([ - { - agentId: "with-defaults", - triggerId: "heartbeat-defaults", - kind: "heartbeat", - intervalSeconds: 900, - prompt: "Monitor queue depth", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "dm", - userId: "U999", - }, - }, - { - agentId: "channel-fallback", - triggerId: "cron-fallback", - kind: "cron", - schedule: "*/10 * * * *", - prompt: "Post summary", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C222", - }, - }, - ]); - }); - - it("ignores disabled and invalid proactive trigger entries", () => { - const triggers = resolveProactiveTriggers([ - { - id: "data-analyst", - channel_id: null, - config: { - proactiveTriggers: [ - { - id: "disabled-trigger", - kind: "heartbeat", - intervalSeconds: 300, - prompt: "skip", - enabled: false, - }, - { - id: "missing-delivery-and-channel", - kind: "cron", - schedule: "0 * * * *", - prompt: "skip", - }, - { - id: "bad-heartbeat", - kind: "heartbeat", - intervalSeconds: 2, - prompt: "skip", - delivery: { - surface: "slack", - mode: "dm", - userId: "U123", - }, - }, - ], - }, - } satisfies ActiveAgentProactiveRow, - ]); - - expect(triggers).toEqual([]); - }); - - it("ignores malformed trigger entries without throwing", () => { - const triggers = resolveProactiveTriggers([ - { - id: "data-analyst", - channel_id: "C123", - config: { - proactiveTriggers: [ - null, - { - id: "hourly", - kind: "heartbeat", - intervalSeconds: 900, - prompt: "Run heartbeat check", - }, - ], - } as unknown as ActiveAgentProactiveRow["config"], - } satisfies ActiveAgentProactiveRow, - ]); - - expect(triggers).toEqual([ - { - agentId: "data-analyst", - triggerId: "hourly", - kind: "heartbeat", - intervalSeconds: 900, - prompt: "Run heartbeat check", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C123", - }, - }, - ]); - }); - - it("propagates enabled quiet-hours policy into resolved triggers", () => { - const triggers = resolveProactiveTriggers([ - { - id: "data-analyst", - channel_id: "C123", - config: { - policy: { - quietHours: { - enabled: true, - timezone: "America/Los_Angeles", - startHour: 22, - endHour: 7, - daysOfWeek: [1, 2, 3, 4, 5], - }, - }, - proactiveTriggers: [ - { - id: "daily", - kind: "cron", - schedule: "0 9 * * *", - prompt: "run daily", - }, - ], - }, - } satisfies ActiveAgentProactiveRow, - ]); - - expect(triggers).toEqual([ - { - agentId: "data-analyst", - triggerId: "daily", - kind: "cron", - schedule: "0 9 * * *", - prompt: "run daily", - sessionMode: "isolated", - delivery: { - surface: "slack", - mode: "channel_thread", - channelId: "C123", - }, - quietHours: { - timezone: "America/Los_Angeles", - startHour: 22, - endHour: 7, - daysOfWeek: [1, 2, 3, 4, 5], - }, - }, - ]); - }); -}); diff --git a/tests/runtime/session-key.test.ts b/tests/runtime/session-key.test.ts new file mode 100644 index 0000000..cd4ef45 --- /dev/null +++ b/tests/runtime/session-key.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from "vitest"; +import { + buildSlashSessionKey, + buildMessageSessionKey, + buildProactiveSessionKey, + buildSlashThreadSessionKey, +} from "../../src/runtime/session-key.js"; + +describe("buildSlashThreadSessionKey", () => { + it("uses canonical thread session pattern for slash command runs", () => { + expect( + buildSlashThreadSessionKey("data-analyst", "1740000000.123456"), + ).toBe("data-analyst:1740000000.123456"); + }); +}); + +describe("buildSlashSessionKey", () => { + it("uses main mode pattern", () => { + expect( + buildSlashSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: "1740000000.123456", + sourceEventId: "slash:1", + sessionMode: "main", + }), + ).toBe("data-analyst:main"); + }); + + it("uses isolated mode pattern", () => { + expect( + buildSlashSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: "1740000000.123456", + sourceEventId: "slash:2", + sessionMode: "isolated", + }), + ).toBe("data-analyst:slash:2"); + }); + + it("uses thread mode pattern", () => { + expect( + buildSlashSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: "1740000000.123456", + sourceEventId: "slash:3", + sessionMode: "thread", + }), + ).toBe("data-analyst:1740000000.123456"); + }); + + it("falls back to channel affinity key when threadTs is blank", () => { + expect( + buildSlashSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: " ", + sourceEventId: "slash:4", + sessionMode: "thread", + }), + ).toBe("data-analyst:C123"); + }); +}); + +describe("buildMessageSessionKey", () => { + it("uses main mode pattern", () => { + expect( + buildMessageSessionKey({ + agentId: "data-analyst", + channelId: "D123", + threadTs: "1740000000.1", + sourceEventId: "evt-1", + sessionMode: "main", + isDirectMessage: true, + }), + ).toBe("data-analyst:main"); + }); + + it("uses isolated mode pattern", () => { + expect( + buildMessageSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: "1740000000.1", + sourceEventId: "evt-2", + sessionMode: "isolated", + isDirectMessage: false, + }), + ).toBe("data-analyst:evt-2"); + }); + + it("uses thread mode threadTs pattern for channel threads", () => { + expect( + buildMessageSessionKey({ + agentId: "data-analyst", + channelId: "C123", + threadTs: "1740000000.1", + sourceEventId: "evt-3", + sessionMode: "thread", + isDirectMessage: false, + }), + ).toBe("data-analyst:1740000000.1"); + }); + + it("uses DM thread fallback channel pattern when in thread mode", () => { + expect( + buildMessageSessionKey({ + agentId: "data-analyst", + channelId: "D123", + threadTs: "1740000000.1", + sourceEventId: "evt-4", + sessionMode: "thread", + isDirectMessage: true, + }), + ).toBe("data-analyst:D123"); + }); +}); + +describe("buildProactiveSessionKey", () => { + it("uses main mode proactive session pattern", () => { + expect( + buildProactiveSessionKey({ + agentId: "data-analyst", + triggerId: "heartbeat", + sourceEventId: "proactive:1", + sessionMode: "main", + }), + ).toBe("data-analyst:main"); + }); + + it("uses thread mode proactive session pattern", () => { + expect( + buildProactiveSessionKey({ + agentId: "data-analyst", + triggerId: "heartbeat", + sourceEventId: "proactive:2", + sessionMode: "thread", + }), + ).toBe("data-analyst:proactive:heartbeat:thread"); + }); + + it("uses isolated mode proactive session pattern", () => { + expect( + buildProactiveSessionKey({ + agentId: "data-analyst", + triggerId: "heartbeat", + sourceEventId: "proactive:3", + sessionMode: "isolated", + }), + ).toBe("data-analyst:proactive:heartbeat:proactive:3"); + }); +}); diff --git a/tests/runtime/slash-command-router.test.ts b/tests/runtime/slash-command-router.test.ts deleted file mode 100644 index 1e9b66a..0000000 --- a/tests/runtime/slash-command-router.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createDefaultSlashCommandAgentMap, - normalizeSlashCommand, - resolveAgentIdForSlashCommand, -} from "../../src/runtime/slash-command-router.js"; - -describe("normalizeSlashCommand", () => { - it("normalizes case and whitespace", () => { - expect(normalizeSlashCommand(" /Wiggs ")).toBe("/wiggs"); - }); -}); - -describe("resolveAgentIdForSlashCommand", () => { - it("resolves known commands from the default map", () => { - const map = createDefaultSlashCommandAgentMap(); - - expect(resolveAgentIdForSlashCommand("/wiggs", map)).toBe("data-analyst"); - expect(resolveAgentIdForSlashCommand("/COMPLIANCE", map)).toBe( - "compliance-helper", - ); - }); - - it("returns null for unknown commands", () => { - const map = createDefaultSlashCommandAgentMap(); - - expect(resolveAgentIdForSlashCommand("/unknown", map)).toBeNull(); - }); -}); diff --git a/tests/runtime/trigger-normalizer.test.ts b/tests/runtime/trigger-normalizer.test.ts deleted file mode 100644 index a273859..0000000 --- a/tests/runtime/trigger-normalizer.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - normalizeProactiveTrigger, - normalizeSlackMessageTrigger, - normalizeSlackSlashCommandTrigger, - normalizeSystemTrigger, -} from "../../src/runtime/trigger-normalizer.js"; - -describe("trigger-normalizer", () => { - it("normalizes system triggers", () => { - expect(normalizeSystemTrigger()).toEqual({ - triggerKind: "system", - surface: "system", - entrypoint: "system", - }); - }); - - it("normalizes slash command triggers", () => { - expect(normalizeSlackSlashCommandTrigger()).toEqual({ - triggerKind: "message", - surface: "slack", - entrypoint: "slash_command", - }); - }); - - it("normalizes non-slash message triggers", () => { - expect(normalizeSlackMessageTrigger("app_mention")).toEqual({ - triggerKind: "message", - surface: "slack", - entrypoint: "app_mention", - }); - expect(normalizeSlackMessageTrigger("thread_reply")).toEqual({ - triggerKind: "message", - surface: "slack", - entrypoint: "thread_reply", - }); - expect(normalizeSlackMessageTrigger("direct_message")).toEqual({ - triggerKind: "message", - surface: "slack", - entrypoint: "direct_message", - }); - }); - - it("normalizes proactive triggers", () => { - expect(normalizeProactiveTrigger("cron")).toEqual({ - triggerKind: "cron", - surface: "system", - entrypoint: "cron", - }); - expect(normalizeProactiveTrigger("heartbeat")).toEqual({ - triggerKind: "heartbeat", - surface: "system", - entrypoint: "heartbeat", - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 81347ee..e549e26 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,9 +11,9 @@ "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "rootDir": "src", + "rootDir": ".", "outDir": "dist", "types": ["node"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "agents/**/*.ts"] }