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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- `@agentworkforce/mcp-workforce` now uses zod 4 with
`@modelcontextprotocol/sdk` `^1.31.0`. The floor is `zod` `^4.4.3`, so
consumers can pin 4.4.3 (zod 4.5+ grows `zod/v4/core` substantially).
- `@agentworkforce/deploy`, `@agentworkforce/cli`, `@agentworkforce/runtime`
and `@agentworkforce/local-surface` moved to the zod-4 `@agent-relay/*`
lines (`@agent-relay/cloud` and `@agent-relay/fleet` `^12.2.2`,
`@agent-relay/events` `^7.1.1`), so installs no longer pull zod 3 through them.
- `@agentworkforce/local-surface` `spawn:persona` now requires
`@agent-relay/fleet` 12 or newer (README and runtime guard updated).
- `@agentworkforce/runtime` accepts `agent-trajectories` `^0.5.3 || ^0.6.0 || ^0.7.0`,
so consumers can dedupe onto the zod-4 trajectories release once it ships.

## [4.1.60] - 2026-09-30

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,14 @@
"@grpc/proto-loader>protobufjs": ">=7.5.8 <8.0.0",
"hono": ">=4.12.21",
"ws": ">=8.20.1",
"qs": ">=6.15.2"
"qs": ">=6.15.2",
"zod@^4": "4.4.3"
}
},
"devDependencies": {
"@relaycast/a2a": "^6.2.0",
"@types/node": "^22.18.0",
"agent-trajectories": "^0.5.3",
"agent-trajectories": "^0.5.3 || ^0.6.0 || ^0.7.0",
"tsx": "^4.23.1",
"typescript": "^5.9.2"
},
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"package.json"
],
"dependencies": {
"@agent-relay/cloud": "^10.1.0",
"@agent-relay/cloud": "^12.2.2",
"@agentworkforce/deploy": "workspace:*",
"@agentworkforce/events": "workspace:*",
"@agentworkforce/local-surface": "workspace:*",
Expand Down
2 changes: 1 addition & 1 deletion packages/deploy/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@agent-relay/cloud": "^10.1.0",
"@agent-relay/cloud": "^12.2.2",
"@agentworkforce/persona-kit": "workspace:*",
"@agentworkforce/runtime": "workspace:*",
"@daytona/sdk": "^0.205.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/local-surface/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ its skills, MCP servers, sidecars, harness, model, and harness settings. A
request `task` is delivered separately as the concrete assignment. Concurrent
requests for the same node, project, persona, and agent name share one launch, and the
Relay broker verifies node registration plus the harness `worker_ready`
handshake before the action succeeds. This path requires Agent Relay 11.5 or
handshake before the action succeeds. This path requires Agent Relay 12 or
newer. The isolated mount auto-syncs agent changes back to the project and
flushes once more during teardown.

Expand Down
2 changes: 1 addition & 1 deletion packages/local-surface/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"lint": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@agent-relay/fleet": "^11.5.0",
"@agent-relay/fleet": "^12.2.2",
Comment thread
willwashburn marked this conversation as resolved.
"@agentworkforce/deploy": "workspace:*",
"@agentworkforce/persona-kit": "workspace:*",
"@agentworkforce/persona-registry": "workspace:*",
Expand Down
59 changes: 58 additions & 1 deletion packages/local-surface/src/persona-spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type { ResolvedPersonaReference } from '@agentworkforce/persona-registry'

import {
__setPersonaSpawnImplementationsForTest,
defineWorkforcePersonaSpawnNode
checkFleetSdkCompatibility,
createCachedFleetVersionReader,
defineWorkforcePersonaSpawnNode,
MIN_FLEET_MAJOR
} from './persona-spawn.js';

const resolved: ResolvedPersonaReference = {
Expand Down Expand Up @@ -419,3 +422,57 @@ test('host disposes the real executor mount retained after a successful spawn',
await rm(project, { recursive: true, force: true });
}
});

test('fleet compatibility guard requires Fleet 12 or newer', () => {
assert.equal(MIN_FLEET_MAJOR, 12);
assert.doesNotThrow(() => checkFleetSdkCompatibility({ dynamicSpawnDelegation: true, version: '12.2.2' }));
assert.doesNotThrow(() => checkFleetSdkCompatibility({ dynamicSpawnDelegation: true, version: '13.0.0' }));
// Version unreadable (e.g. bundled): fall back to the capability flag.
assert.doesNotThrow(() => checkFleetSdkCompatibility({ dynamicSpawnDelegation: true, version: undefined }));
assert.throws(
() => checkFleetSdkCompatibility({ dynamicSpawnDelegation: true, version: '11.11.0' }),
/requires @agent-relay\/fleet 12 or newer.*found 11\.11\.0/
);
assert.throws(
() => checkFleetSdkCompatibility({ dynamicSpawnDelegation: undefined, version: undefined }),
/requires @agent-relay\/fleet 12 or newer/
);
});

test('fleet compatibility guard accepts the installed Fleet SDK', async () => {
__setPersonaSpawnImplementationsForTest();
const node = defineWorkforcePersonaSpawnNode({ nodeName: 'persona-node', cwd: '/tmp/project' });
const ctx = {
node: { name: 'persona-node', capabilities: ['spawn:persona'] },
relay: { sendMessage: async () => undefined },
spawnAgent: async () => {
throw new Error('spawnAgent must not be reached');
}
} satisfies FleetActionContext;
// The real guard runs first; with the installed v12 SDK it must not be the
// reason the action fails (input validation rejects the empty payload).
await assert.rejects(
invokeNodeHandler(node, 'spawn:persona', {}, ctx),
(error: Error) => !/requires @agent-relay\/fleet/.test(error.message) && /name/.test(error.message)
);
});

test('fleet version lookup is memoized, including an unreadable result', () => {
let reads = 0;
const readVersion = createCachedFleetVersionReader(() => {
reads += 1;
return '12.4.1';
});
assert.equal(readVersion(), '12.4.1');
assert.equal(readVersion(), '12.4.1');
assert.equal(reads, 1);

let missingReads = 0;
const readMissing = createCachedFleetVersionReader(() => {
missingReads += 1;
return undefined;
});
assert.equal(readMissing(), undefined);
assert.equal(readMissing(), undefined);
assert.equal(missingReads, 1);
});
78 changes: 70 additions & 8 deletions packages/local-surface/src/persona-spawn.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { existsSync, readFileSync } from 'node:fs';
import { mkdtemp, rm } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { join, resolve as resolvePath } from 'node:path';
import { dirname, join, resolve as resolvePath } from 'node:path';

import * as fleetSdk from '@agent-relay/fleet';
import {
Expand Down Expand Up @@ -269,14 +271,74 @@ async function launchResolvedPersona(input: {
}
}

/** Oldest `@agent-relay/fleet` major this package is built and tested against. */
export const MIN_FLEET_MAJOR = 12;

const FLEET_COMPATIBILITY_ERROR = `spawn:persona requires @agent-relay/fleet ${MIN_FLEET_MAJOR} or newer; older Fleet versions are not supported by @agentworkforce/local-surface`;

/**
* Validate the loaded Fleet SDK. `version` is the installed package version
* when it can be read; when it cannot (e.g. a bundled build without the
* package manifest) only the capability flag is checked.
* @internal Exported for tests; not part of the stable API.
*/
export function checkFleetSdkCompatibility(sdk: {
dynamicSpawnDelegation: unknown;
version: string | undefined;
}): void {
if (sdk.dynamicSpawnDelegation !== true) {
throw new Error(FLEET_COMPATIBILITY_ERROR);
}
if (sdk.version !== undefined) {
const major = Number.parseInt(sdk.version, 10);
if (!Number.isFinite(major) || major < MIN_FLEET_MAJOR) {
throw new Error(`${FLEET_COMPATIBILITY_ERROR} (found ${sdk.version})`);
}
}
}

function assertFleetCompatibility(): void {
const supported = (
fleetSdk as unknown as { FLEET_DYNAMIC_SPAWN_DELEGATION?: unknown }
).FLEET_DYNAMIC_SPAWN_DELEGATION;
if (supported !== true) {
throw new Error(
'spawn:persona requires @agent-relay/fleet 11.5 or newer; older Fleet versions cannot delegate a persona action to its resolved harness'
);
checkFleetSdkCompatibility({
dynamicSpawnDelegation: (fleetSdk as unknown as { FLEET_DYNAMIC_SPAWN_DELEGATION?: unknown })
.FLEET_DYNAMIC_SPAWN_DELEGATION,
version: installedFleetVersion()
});
}

/**
* Memoize a Fleet version reader so the manifest lookup (sync fs I/O) runs at
* most once per process; an unreadable result (`undefined`) is cached too.
* @internal Exported for tests; not part of the stable API.
*/
export function createCachedFleetVersionReader(
read: () => string | undefined
): () => string | undefined {
let cached: { version: string | undefined } | undefined;
return () => {
cached ??= { version: read() };
return cached.version;
};
}

const installedFleetVersion = createCachedFleetVersionReader(readInstalledFleetVersion);

function readInstalledFleetVersion(): string | undefined {
Comment thread
willwashburn marked this conversation as resolved.
try {
let dir = dirname(createRequire(import.meta.url).resolve('@agent-relay/fleet'));
for (;;) {
const manifest = join(dir, 'package.json');
if (existsSync(manifest)) {
const parsed = JSON.parse(readFileSync(manifest, 'utf8')) as { name?: unknown; version?: unknown };
if (parsed.name === '@agent-relay/fleet') {
return typeof parsed.version === 'string' ? parsed.version : undefined;
}
}
const parent = dirname(dir);
if (parent === dir) return undefined;
dir = parent;
}
} catch {
return undefined;
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/mcp-workforce/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
"@agentworkforce/deploy": "workspace:*",
"@agentworkforce/persona-kit": "workspace:*",
"@agentworkforce/runtime": "workspace:*",
"@modelcontextprotocol/sdk": "^1.21.0",
"zod": "^3.23.8"
"@modelcontextprotocol/sdk": "^1.31.0",
"zod": "^4.4.3"
Comment thread
willwashburn marked this conversation as resolved.
}
}
38 changes: 38 additions & 0 deletions packages/mcp-workforce/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,41 @@ test('createWorkforceMcpServer registers the documented tool set', () => {
'workflow.status'
]);
});

test('tool input schemas are advertised as JSON Schema and enforced over the MCP protocol', async () => {
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
const { InMemoryTransport } = await import('@modelcontextprotocol/sdk/inMemory.js');
const config = loadConfig({
WORKFORCE_WORKSPACE_ID: 'ws-demo',
WORKFORCE_RUNTIME_TOKEN: 'tok',
SUPERMEMORY_API_KEY: 'sm',
RELAYFILE_MOUNT_ROOT: '/tmp/wf-mcp-server-test'
});
const server = createWorkforceMcpServer(config);
const client = new Client({ name: 'mcp-workforce-test', version: '0.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
try {
const { tools } = await client.listTools();
const run = tools.find((tool) => tool.name === 'workflow.run');
assert.ok(run, 'workflow.run should be listed');
assert.equal(run.inputSchema.type, 'object');
assert.deepEqual(run.inputSchema.required, ['name']);
const properties = run.inputSchema.properties as Record<string, Record<string, unknown>>;
assert.equal(properties.name?.type, 'string');
assert.equal(properties.name?.minLength, 1);
assert.equal(properties.args?.type, 'object');

const review = tools.find((tool) => tool.name === 'integration.github.postReview');
const reviewProps = review?.inputSchema.properties as Record<string, Record<string, unknown>>;
const event = (reviewProps.review?.properties as Record<string, Record<string, unknown>>)?.event;
assert.deepEqual(event?.enum, ['COMMENT', 'APPROVE', 'REQUEST_CHANGES']);

// Invalid input is rejected by the zod schema before the handler runs.
const invalid = await client.callTool({ name: 'workflow.status', arguments: { runId: '' } });
assert.equal(invalid.isError, true);
} finally {
await client.close();
await server.close();
}
});
4 changes: 2 additions & 2 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,11 @@
},
"dependencies": {
"@agent-assistant/proactive": "^0.4.32",
"@agent-relay/events": "^6.3.3",
"@agent-relay/events": "^7.1.1",
"@agentworkforce/events": "workspace:*",
"@agentworkforce/persona-kit": "workspace:*",
"@relayfile/adapter-core": "^0.5.9",
"@relayfile/relay-helpers": "^0.4.7",
"agent-trajectories": "^0.5.3"
"agent-trajectories": "^0.5.3 || ^0.6.0 || ^0.7.0"
}
}
34 changes: 34 additions & 0 deletions packages/runtime/src/trajectory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,40 @@ test('records a run and emits a contract-shaped artifact', async () => {
});
});

test('stamps the run workflowId onto the raw trajectory', async () => {
await withRoot(async (root) => {
const recorder = createTrajectoryRecorder({
personaId: 'demo',
agentName: 'demo',
workspaceId: 'ws-1',
trajectoryRoot: root,
log: silentLog,
autoCompact: false
});

await recorder.begin(cronEvent);
await recorder.context.done('did the thing', 0.9);

const contract = await readOnlyContract(root, 'demo');
// The storage layout differs across agent-trajectories releases, so find
// the raw trajectory by id rather than by path.
const dataDir = path.join(root, 'demo');
const candidates = (await readdir(dataDir, { recursive: true })).filter(
(f) => f.endsWith('.json') && !f.startsWith('compacted')
);
const raws: Array<{ id?: string; workflowId?: string }> = [];
for (const file of candidates) {
const parsed = JSON.parse(await readFile(path.join(dataDir, file), 'utf8')) as {
id?: string;
workflowId?: string;
};
if (parsed.id === contract.id) raws.push(parsed);
}
assert.ok(raws.length > 0, `no raw trajectory for ${contract.id} in ${candidates.join(', ')}`);
for (const raw of raws) assert.equal(raw.workflowId, 'run_evt-1_1');
});
});

test('auto-finalizes on complete() when the handler did not call done()', async () => {
await withRoot(async (root) => {
const recorder = createTrajectoryRecorder({
Expand Down
10 changes: 8 additions & 2 deletions packages/runtime/src/trajectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import type {
WorkforceEvent
} from './types.js';

type TrajectoryStartOptions = NonNullable<Parameters<TrajectoryClient['start']>[1]>;

/** Stable display label for an event's origin (provider slug, or `cron`). */
function eventSourceLabel(event: WorkforceEvent): string {
return isCronTickEvent(event) ? 'cron' : event.resource.provider;
Expand Down Expand Up @@ -169,11 +171,15 @@ class ActiveTrajectoryRecorder implements TrajectoryRecorder {
await stale?.abandon('superseded by a new run');
}
const { title, description } = describeEvent(event);
this.session = await this.client.start(title, {
// Every supported agent-trajectories release stamps `options.workflowId`
// at runtime, but the 0.5.5–0.6.x type declarations omit the field, so
// build the options outside the call to skip the excess-property check.
const startOptions: TrajectoryStartOptions & { workflowId: string } = {
...(description ? { description } : {}),
workflowId: workflowIdFor(event),
tags: [`persona:${this.personaId}`, `workspace:${event.workspace}`, `source:${eventSourceLabel(event)}`]
});
};
this.session = await this.client.start(title, startOptions);
await this.session.chapter(`handle ${eventLabel(event)}`);
} catch (err) {
this.session = null;
Expand Down
Loading
Loading