diff --git a/packages/runtime/package.json b/packages/runtime/package.json index cd11993..66fbb2e 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -22,6 +22,10 @@ "types": "./dist/raw.d.ts", "default": "./dist/raw.js" }, + "./proactive": { + "types": "./dist/proactive.d.ts", + "default": "./dist/proactive.js" + }, "./package.json": "./package.json" }, "files": [ @@ -45,6 +49,7 @@ "lint": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@agent-assistant/proactive": "^0.4.32", "@agentworkforce/persona-kit": "workspace:*" } } diff --git a/packages/runtime/src/proactive.test.ts b/packages/runtime/src/proactive.test.ts new file mode 100644 index 0000000..8a1c8f7 --- /dev/null +++ b/packages/runtime/src/proactive.test.ts @@ -0,0 +1,135 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { schedulerBindingFromCtx, toProactiveSession } from './proactive.js'; +import type { WorkforceCtx } from './types.js'; + +function fakeCtx(over: Partial = {}): WorkforceCtx { + const scheduleAt: Array<{ at: Date; payload: unknown }> = []; + const scheduleCancel: string[] = []; + return { + persona: { + id: 'demo', + intent: 'documentation', + tags: ['documentation'], + description: '', + skills: [], + harness: 'claude', + model: 'anthropic/claude-3-5-sonnet', + systemPrompt: 'be helpful', + harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } + }, + workspaceId: 'ws-acme', + agentName: 'reviewer', + llm: { + async complete() { + throw new Error('not configured'); + } + }, + harness: { + async run() { + return { output: '', exitCode: 0, durationMs: 0 }; + } + }, + sandbox: { + cwd: '/tmp', + async exec() { + return { output: '', exitCode: 0 }; + }, + async readFile() { + return ''; + }, + async writeFile() { + /* no-op */ + } + }, + memory: { + async save() { + /* no-op */ + }, + async recall() { + return []; + } + }, + workflow: { + async run() { + throw new Error('not configured'); + }, + async status() { + throw new Error('not configured'); + } + }, + schedule: { + async at(at, payload) { + scheduleAt.push({ at, payload }); + }, + async cancel(name) { + scheduleCancel.push(name); + } + }, + log: () => undefined, + ...over + } as WorkforceCtx & { + schedule: WorkforceCtx['schedule'] & { + _at: typeof scheduleAt; + _cancel: typeof scheduleCancel; + }; + }; +} + +test('toProactiveSession builds a stable session descriptor from ctx', () => { + const ctx = fakeCtx(); + const session = toProactiveSession(ctx); + // RuntimeInteropSession shape: stable id keyed by workspace + agent. + assert.equal(session.id, 'ws-acme:reviewer'); + assert.equal(session.userId, 'agent:ws-acme:reviewer'); + assert.equal(session.workspaceId, 'ws-acme'); + assert.match(session.surfaceId, /^proactive-runtime:ws-acme:reviewer$/); + assert.equal(session.metadata.source, 'proactive-runtime'); + assert.equal(session.metadata.agentId, 'reviewer'); +}); + +test('toProactiveSession honors an explicit agentId override', () => { + const session = toProactiveSession(fakeCtx(), { agentId: 'alt-agent' }); + assert.equal(session.id, 'ws-acme:alt-agent'); + assert.equal(session.metadata.agentId, 'alt-agent'); +}); + +test('schedulerBindingFromCtx routes requestWakeUp through ctx.schedule.at', async () => { + const calls: Array<{ at: Date; payload: unknown }> = []; + const ctx = fakeCtx({ + schedule: { + async at(at, payload) { + calls.push({ at, payload }); + }, + async cancel() { + /* unused here */ + } + } + }); + const binding = schedulerBindingFromCtx(ctx); + const at = new Date('2026-05-13T09:00:00Z'); + const id = await binding.requestWakeUp(at, { reason: 'follow-up' } as never); + assert.equal(calls.length, 1); + assert.equal(calls[0].at.toISOString(), at.toISOString()); + // The bindingId is a stable per-agent slot name so a pre-registered + // persona schedule slot can be cancelled by `cancelWakeUp`. It is not + // per-timestamp, since `ctx.schedule.at` does not accept caller names. + assert.equal(id, 'proactive-reviewer'); +}); + +test('schedulerBindingFromCtx routes cancelWakeUp through ctx.schedule.cancel', async () => { + const cancelled: string[] = []; + const ctx = fakeCtx({ + schedule: { + async at() { + /* unused here */ + }, + async cancel(name) { + cancelled.push(name); + } + } + }); + const binding = schedulerBindingFromCtx(ctx); + await binding.cancelWakeUp('proactive-reviewer'); + assert.deepEqual(cancelled, ['proactive-reviewer']); +}); diff --git a/packages/runtime/src/proactive.ts b/packages/runtime/src/proactive.ts new file mode 100644 index 0000000..ca1be95 --- /dev/null +++ b/packages/runtime/src/proactive.ts @@ -0,0 +1,102 @@ +/** + * Bridge between workforce's `WorkforceCtx` and the + * `@agent-assistant/proactive` runtime-interop primitives. + * + * The agent-assistant proactive package exposes two pieces workforce can + * compose with: + * + * - `fromContext({ workspaceId, agentId })` → a stable + * `RuntimeInteropSession` descriptor agent-assistant's session/memory/ + * scheduling primitives consume. This is how workforce handlers + * can call into agent-assistant tooling without re-rolling the + * session-key convention. + * - `ContextSchedulerBinding` (re-exported as `RuntimeSchedulerBinding`) + * — a `SchedulerBinding` implementation that delegates to a + * `scheduleWakeUp`/`cancelWakeUp` pair supplied on a runtime ctx. + * The workforce runtime's `ctx.schedule.at` / `ctx.schedule.cancel` + * methods have the same shape, so this binding lets the proactive + * engine drive wake-ups through workforce's schedule context. + * + * Today the bridge is opt-in: handlers import `toProactiveSession(ctx)` + * or `schedulerBindingFromCtx(ctx)` when they need agent-assistant + * primitives. The runtime itself does not auto-wire either. When the + * workforce side adopts agent-assistant sessions for stateful turn + * tracking, the wiring lifts up into `buildCtx`. + */ + +import { + ContextSchedulerBinding, + fromContext as proactiveFromContext, + type RuntimeInteropSession, + type RuntimeScheduleContext +} from '@agent-assistant/proactive'; +import type { WorkforceCtx } from './types.js'; + +/** + * Map a workforce ctx into the `RuntimeInteropSession` shape + * agent-assistant's session-scoped primitives expect. + * + * `agentId` defaults to `ctx.agentName` (which itself defaults to + * `ctx.persona.id`). Callers who need a different agent identity (e.g. + * one workforce ctx that fans out to multiple agent-assistant sessions) + * pass `agentId` explicitly. + */ +export function toProactiveSession( + ctx: WorkforceCtx, + options: { agentId?: string } = {} +): RuntimeInteropSession { + return proactiveFromContext({ + workspaceId: ctx.workspaceId, + agentId: options.agentId ?? ctx.agentName + }); +} + +/** + * Construct a `SchedulerBinding` that routes wake-up requests through a + * workforce ctx. Pass the binding into `createProactiveEngine` to let + * agent-assistant's proactive engine schedule its own follow-ups using + * workforce's `ctx.schedule.at` / `ctx.schedule.cancel`. + * + * IMPORTANT: the adapter closes over the supplied `ctx`, so the binding + * must be rebuilt per event invocation. Reusing a binding constructed + * with a previous invocation's ctx would route wake-ups through stale + * schedule / sandbox / workspace handles. Treat the binding as request- + * scoped, the same way `ctx` itself is. + * + * Cancellation caveat: `ctx.schedule.at` does not currently accept a + * caller-supplied name — schedule names are owned by the persona's + * declared `schedules[]` list. `cancelWakeUp` therefore only works if + * the caller has pre-registered a persona schedule slot whose name + * matches the returned `bindingId` (the deterministic + * `proactive-${agentName}` key below). Otherwise `cancelWakeUp` is a + * no-op against the underlying scheduler. + */ +export function schedulerBindingFromCtx(ctx: WorkforceCtx): ContextSchedulerBinding { + const slotName = bindingSlotFor(ctx.agentName); + const adapter: RuntimeScheduleContext = { + scheduleWakeUp: async (at, context) => { + await ctx.schedule.at(at, context); + // Workforce's `ctx.schedule.cancel` takes a schedule name from the + // persona's `schedules[]` list. We return a stable per-agent slot + // name so a matching pre-registered persona schedule (e.g. + // `proactive-${agentName}`) can be cancelled by `cancelWakeUp`. + return { bindingId: slotName }; + }, + cancelWakeUp: async (bindingId) => { + await ctx.schedule.cancel(bindingId); + } + }; + return new ContextSchedulerBinding(adapter); +} + +function bindingSlotFor(agentName: string): string { + return `proactive-${agentName}`; +} + +// Re-export the underlying types so callers can build their own adapters +// without a second import from `@agent-assistant/proactive`. +export type { + RuntimeInteropSession, + RuntimeScheduleContext +} from '@agent-assistant/proactive'; +export { ContextSchedulerBinding, RuntimeSchedulerBinding } from '@agent-assistant/proactive'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8c3cbf..6498231 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -76,6 +76,9 @@ importers: packages/runtime: dependencies: + '@agent-assistant/proactive': + specifier: ^0.4.32 + version: 0.4.32 '@agentworkforce/persona-kit': specifier: workspace:* version: link:../persona-kit @@ -88,6 +91,18 @@ importers: packages: + '@agent-assistant/connectivity@0.2.24': + resolution: {integrity: sha512-Nkrv8xJnQrX+6nzGVqnBGdcit6yqsF0mA4rk2kfX2Kduv14lVyVM9wjpLWLLF165v7LifCg/VlatYBPTEbLxRw==} + + '@agent-assistant/coordination@0.4.32': + resolution: {integrity: sha512-qepgjfDSIbARR/do4oHhx0GytjIsNzC/ETZKRtEeF1/fWzHp9zLfUBAz9/KjX3EUWNxcQ3LMVNeR/Ai3qpUtZQ==} + + '@agent-assistant/proactive@0.4.32': + resolution: {integrity: sha512-Bl2iXw7Iyyyr47+1c7E8Y9jpnezA7MMA9SnfxDEk1p1WKR77+XIRrKwgHiytRok6qJbb4WjfXEfsO3ExhFXEnQ==} + + '@agent-assistant/surfaces@0.4.32': + resolution: {integrity: sha512-VI1UpwDD/RznfngRKatqL3zkZ6Bo9MszIGwZ5/H68r+6yghsFeTSq42eyoQM90DuQniX6Z/QfPmA890U1ZA2MQ==} + '@aws-crypto/crc32@5.2.0': resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} engines: {node: '>=16.0.0'} @@ -1281,6 +1296,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -1462,6 +1482,23 @@ packages: snapshots: + '@agent-assistant/connectivity@0.2.24': + dependencies: + nanoid: 5.1.11 + + '@agent-assistant/coordination@0.4.32': + dependencies: + '@agent-assistant/connectivity': 0.2.24 + nanoid: 5.1.11 + + '@agent-assistant/proactive@0.4.32': + dependencies: + '@agent-assistant/coordination': 0.4.32 + '@agent-assistant/surfaces': 0.4.32 + nanoid: 5.1.11 + + '@agent-assistant/surfaces@0.4.32': {} + '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 @@ -2970,6 +3007,8 @@ snapshots: ms@2.1.3: {} + nanoid@5.1.11: {} + node-addon-api@7.1.1: {} onetime@7.0.0: